diff --git a/lib/kolab_sync_backend_folder.php b/lib/kolab_sync_backend_folder.php index adcf3bd..23e19fe 100644 --- a/lib/kolab_sync_backend_folder.php +++ b/lib/kolab_sync_backend_folder.php @@ -1,199 +1,202 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * Kolab backend class for the folder state storage */ class kolab_sync_backend_folder extends kolab_sync_backend_common implements Syncroton_Backend_IFolder { protected $table_name = 'syncroton_folder'; protected $interface_name = 'Syncroton_Model_IFolder'; /** * Delete all stored folder ids for a given device * * @param Syncroton_Model_Device|string $deviceid Device object or identifier */ public function resetState($deviceid) { $device_id = $deviceid instanceof Syncroton_Model_IDevice ? $deviceid->id : $deviceid; $where[] = $this->db->quote_identifier('device_id') . ' = ' . $this->db->quote($device_id); $this->db->query('DELETE FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where)); } /** * Get array of ids which got send to the client for a given class * * @param Syncroton_Model_Device|string $deviceid Device object or identifier * @param string $class Class name * @param int $syncKey Sync key * * @return array List of object identifiers */ public function getFolderState($deviceid, $class, $syncKey = null) { $device_id = $deviceid instanceof Syncroton_Model_IDevice ? $deviceid->id : $deviceid; $where[] = $this->db->quote_identifier('device_id') . ' = ' . $this->db->quote($device_id); $where[] = $this->db->quote_identifier('class') . ' = ' . $this->db->quote($class); if ($syncKey) { $where[] = $this->db->quote_identifier('creation_synckey') . ' < ' . $this->db->quote($syncKey + 1); } $select = $this->db->query('SELECT * FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where)); $result = []; while ($folder = $this->db->fetch_assoc($select)) { $result[$folder['folderid']] = $this->get_object($folder); } return $result; } /** * Get folder * * @param Syncroton_Model_Device|string $deviceid Device object or identifier * @param string $folderid Folder identifier * * @return Syncroton_Model_IFolder Folder object */ public function getFolder($deviceid, $folderid) { $device_id = $deviceid instanceof Syncroton_Model_IDevice ? $deviceid->id : $deviceid; $where[] = $this->db->quote_identifier('device_id') . ' = ' . $this->db->quote($device_id); $where[] = $this->db->quote_identifier('folderid') . ' = ' . $this->db->quote($folderid); $select = $this->db->query('SELECT * FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where)); $folder = $this->db->fetch_assoc($select); - if (empty($folder) || !empty($folder['resync'])) { - throw new Syncroton_Exception_NotFound('Folder not found'); + if (!empty($folder['resync'])) { + throw new Syncroton_Exception_NotFound("Folder $folderid not found because of resync"); + } + if (empty($folder)) { + throw new Syncroton_Exception_NotFound("Folder $folderid not found"); } return $this->get_object($folder); } /** * Find out if the folder hierarchy changed since the last FolderSync * * @param Syncroton_Model_Device $device Device object * * @return bool True if folders hierarchy changed, False otherwise */ public function hasHierarchyChanges($device) { $timestamp = new DateTime('now', new DateTimeZone('utc')); $client_crc = ''; $server_crc = ''; $client_folders = []; $server_folders = []; $folder_classes = [ Syncroton_Data_Factory::CLASS_CALENDAR, Syncroton_Data_Factory::CLASS_CONTACTS, Syncroton_Data_Factory::CLASS_EMAIL, Syncroton_Data_Factory::CLASS_NOTES, Syncroton_Data_Factory::CLASS_TASKS, ]; // Reset imap cache so we work with up-to-date folders list rcube::get_instance()->get_storage()->clear_cache('mailboxes', true); // Retrieve all folders already sent to the client $select = $this->db->query("SELECT * FROM `{$this->table_name}` WHERE `device_id` = ?", $device->id); while ($folder = $this->db->fetch_assoc($select)) { if (!empty($folder['resync'])) { // Folder re-sync requested return true; } $client_folders[$folder['folderid']] = $this->get_object($folder); } foreach ($folder_classes as $class) { try { // retrieve all folders available in data backend $dataController = Syncroton_Data_Factory::factory($class, $device, $timestamp); $server_folders = array_merge($server_folders, $dataController->getAllFolders()); } catch (Exception $e) { rcube::raise_error($e, true, false); // This is server error, returning True might cause infinite sync loops return false; } } ksort($client_folders); ksort($server_folders); foreach ($client_folders as $folder) { $client_crc .= '^' . $folder->serverId . ':' . $folder->displayName . ':' . $folder->parentId; } foreach ($server_folders as $folder) { $server_crc .= '^' . $folder->serverId . ':' . $folder->displayName . ':' . $folder->parentId; } return $client_crc !== $server_crc; } /** * (non-PHPdoc) * @see kolab_sync_backend_common::from_camelcase() */ protected function from_camelcase($string) { switch ($string) { case 'displayName': case 'parentId': return strtolower($string); case 'serverId': return 'folderid'; default: return parent::from_camelcase($string); } } /** * (non-PHPdoc) * @see kolab_sync_backend_common::to_camelcase() */ protected function to_camelcase($string, $ucFirst = true) { switch ($string) { case 'displayname': return 'displayName'; case 'parentid': return 'parentId'; case 'folderid': return 'serverId'; default: return parent::to_camelcase($string, $ucFirst); } } } diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php index 5308d5e..ecb2e21 100644 --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -1,1571 +1,1571 @@ | | | | 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 { public const MAX_SEARCH_RESULT = 200; /** * Mapping from ActiveSync Email namespace fields */ protected $mapping = [ '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', ]; public static $memory_accumulated = 0; /** * Special folder type/name map * * @var array */ protected $folder_types = [ 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; protected $storage; /** * 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'; } /** * Encode a globalObjId according to https://interoperability.blob.core.windows.net/files/MS-ASEMAIL/%5bMS-ASEMAIL%5d-150526.pdf 2.2.2.3 * * @param array $data An array with the data to encode * * @return string the encoded globalObjId */ public static function encodeGlobalObjId(array $data): string { $classid = "040000008200e00074c5b7101a82e008"; if (!empty($data['data'])) { $payload = $data['data']; } else { $uid = $data['uid']; $payload = "vCal-Uid\1\0\0\0{$uid}\0"; } $packed = pack( "H32nCCPx8Va*", $classid, $data['year'] ?? 0, $data['month'] ?? 0, $data['day'] ?? 0, $data['now'] ?? 0, strlen($payload), $payload ); return base64_encode($packed); } /** * Decode a globalObjId according to https://interoperability.blob.core.windows.net/files/MS-ASEMAIL/%5bMS-ASEMAIL%5d-150526.pdf 2.2.2.3 * * @param string $globalObjId The encoded globalObjId * * @return array An array with the decoded data */ public static function decodeGlobalObjId(string $globalObjId): array { $unpackString = 'H32classid/nyear/Cmonth/Cday/Pnow/x8/Vbytecount/a*data'; $decoded = unpack($unpackString, base64_decode($globalObjId)); $decoded['uid'] = substr($decoded['data'], strlen("vCal-Uid\1\0\0\0"), -1); return $decoded; } /** * 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 $this->storage->set_folder($message->folder); $this->logger->debug(sprintf("Processing message %s (size: %.2f MB)", $serverId, $headers->size / 1024 / 1024)); // 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([ 'flagType' => 'FollowUp', 'status' => Syncroton_Model_EmailFlag::STATUS_ACTIVE, ]); } else { $result['flag'] = new Syncroton_Model_EmailFlag(); } // Importance/Priority if ($headers->priority) { if ($headers->priority < 3) { $result['importance'] = 2; // High } elseif ($headers->priority > 3) { $result['importance'] = 0; // Low } } // Categories (Tags) $result['categories'] = $headers->others['categories'] ?? []; //The body never changes, so make sure we don't re-send it to the client on a change if ($collection->options['_changesOnly'] ?? false) { return new Syncroton_Model_Email($result); } // 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']; } elseif (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 = [ 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'] ?? 0); $airSyncBaseType = $type; break; } } } $body_params = ['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 $isTruncated = 0; if (empty($prefs)) { $messageBody = ''; $real_length = $headers->size; $truncateAt = 0; $body_length = 0; $isTruncated = 1; } elseif ($airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_MIME) { // Check if we have enough memory to handle the message $messageBody = $this->message_mem_check($message, $headers->size); static::$memory_accumulated += $headers->size; if (empty($messageBody)) { $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 $result['messageClass'] = 'IPM.Note'; $result['contentClass'] = 'urn:content-classes:message'; if ($headers->ctype == 'multipart/signed' && !empty($message->parts[1]) && $message->parts[1]->mimetype == 'application/pkcs7-signature' ) { $result['messageClass'] = 'IPM.Note.SMIME.MultipartSigned'; } elseif ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') { $result['messageClass'] = 'IPM.Note.SMIME'; } elseif ($event = $this->get_invitation_event_from_message($message)) { // Note: Depending on MessageClass a client will display a proper set of buttons // Either Accept/Maybe/Decline (REQUEST), or "Remove from Calendar" (CANCEL) or none (REPLY). $result['messageClass'] = 'IPM.Schedule.Meeting.Request'; $result['contentClass'] = 'urn:content-classes:calendarmessage'; $meeting = []; $meeting['allDayEvent'] = $event['allday'] ?? null ? 1 : 0; $meeting['startTime'] = self::date_from_kolab($event['start']); $meeting['dtStamp'] = self::date_from_kolab($event['dtstamp'] ?? null); $meeting['endTime'] = self::date_from_kolab($event['end'] ?? null); $meeting['location'] = $event['location'] ?? null; $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_NORMAL; if (!empty($event['recurrence_date'])) { $meeting['recurrenceId'] = self::date_from_kolab($event['recurrence_date']); if (!empty($event['status']) && $event['status'] == 'CANCELLED') { $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_EXCEPTION; } else { $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_SINGLE; } } elseif (!empty($event['recurrence'])) { $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_MASTER; // TODO: MeetingRequest recurrence is different that the one in Calendar // $this->recurrence_from_kolab($collection, $event, $meeting); } // Organizer if (!empty($event['attendees'])) { foreach ($event['attendees'] as $attendee) { if (!empty($attendee['role']) && $attendee['role'] == 'ORGANIZER' && !empty($attendee['email'])) { $meeting['organizer'] = $attendee['email']; break; } } } // Current time as a number of 100-nanosecond units since 1601-01-01 $fileTime = ($event['start']->getTimestamp() + 11644473600) * 10000000; // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date $meeting['timeZone'] = kolab_sync_timezone_converter::encodeTimezoneFromDate($event['start']); $meeting['globalObjId'] = self::encodeGlobalObjId([ 'uid' => $event['uid'], 'year' => intval($event['start']->format('Y')), 'month' => intval($event['start']->format('n')), 'day' => intval($event['start']->format('j')), 'now' => $fileTime, ]); if ($event['_method'] == 'REQUEST') { $meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_REQUEST; // Some clients (iOS) without this flag do not send the invitation reply to the organizer. // Note: Microsoft says "the value of the ResponseRequested element comes from the PARTSTAT // parameter value of "NEEDS-ACTION" in the request". I think it is safe to do this for all requests. // Note: This does not have impact on the existence of Accept/Decline buttons in the client. $meeting['responseRequested'] = 1; } else { // REPLY or CANCEL $meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_NORMAL; $itip_processing = kolab_sync::get_instance()->config->get('activesync_itip_processing'); $attendeeStatus = null; if ($itip_processing && empty($headers->flags['SEEN'])) { // Optionally process the message and update the event in recipient's calendar // Warning: Only for development purposes, for now it's better to use wallace $calendar_class = new kolab_sync_data_calendar($this->device, $this->syncTimeStamp); $attendeeStatus = $calendar_class->processItipReply($event); } elseif ($event['_method'] == 'CANCEL') { $attendeeStatus = kolab_sync_data_calendar::ITIP_CANCELLED; } elseif (!empty($event['attendees'])) { // Get the attendee/status in the REPLY foreach ($event['attendees'] as $attendee) { if (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') { if (!empty($attendee['email']) && !empty($attendee['status'])) { // Per iTip spec. there should be only one (non-organizer) attendee here // FIXME: Verify is it realy the case with e.g. Kolab webmail, If not, we should // probably use the message sender from the From: header $attendeeStatus = strtoupper($attendee['status']); break; } } } } switch ($attendeeStatus) { case kolab_sync_data_calendar::ITIP_CANCELLED: $result['messageClass'] = 'IPM.Schedule.Meeting.Canceled'; break; case kolab_sync_data_calendar::ITIP_DECLINED: $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Neg'; break; case kolab_sync_data_calendar::ITIP_TENTATIVE: $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Tent'; break; case kolab_sync_data_calendar::ITIP_ACCEPTED: $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Pos'; break; default: $skipRequest = true; } } // New time proposals aren't supported by Kolab. // This disables the UI elements related to this on the client side $meeting['disallowNewTimeProposal'] = 1; if (empty($skipRequest)) { $result['meetingRequest'] = new Syncroton_Model_EmailMeetingRequest($meeting); } } $is_ios = preg_match('/(iphone|ipad)/i', $this->device->devicetype); // attachments $attachments = array_merge($message->attachments, $message->inline_parts); if (!empty($attachments)) { $result['attachments'] = []; foreach ($attachments as $attachment) { $att = []; if ($is_ios && !empty($event) && $attachment->mime_id == $event['_mime_id']) { continue; } $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([ 'options' => $options, ]); return $this->getEntry($collection, $longId); } /** * convert email from xml to libkolab array * * @param Syncroton_Model_Email $data Email to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * * @return array */ public function toKolab($data, $folderid, $entry = null) { // does nothing => you can't add emails via ActiveSync return []; } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @return array Filter query */ protected function filter($filter_type = 0) { $filter = []; 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 = []; $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] = [ '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 * * @param string $folder_id Folder identifier * * @return array Folder identifiers list */ protected function extractFolders($folder_id) { $list = $this->listFolders(); $result = []; if (!is_array($list)) { - throw new Syncroton_Exception_NotFound('Folder not found'); + throw new Syncroton_Exception_NotFound("Folder $folder_id not found: no folders available"); } // device supports multiple folders? if ($this->isMultiFolder()) { if ($list[$folder_id]) { $result[] = $folder_id; } } elseif ($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'); + throw new Syncroton_Exception_NotFound("Folder $folder_id 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); if (empty($msg)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } $uid = $this->backend->moveItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], $dest_id); return $uid ? $this->serverId($uid, $dest_id) : null; } /** * add entry from xml data * * @param string $folderId Folder identifier * @param Syncroton_Model_Email $entry Entry * * @return string ID of the created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { $params = ['flags' => [!empty($entry->read) ? 'SEEN' : 'UNSEEN']]; $uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry->body->data, $params); if (!$uid) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $this->serverId($uid, $folderId); } /** * 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); if (empty($msg)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $params = ['flags' => []]; if (isset($entry->categories)) { $params['categories'] = $entry->categories; } // Read status change if (isset($entry->read)) { $params['flags'][] = !empty($entry->read) ? 'SEEN' : 'UNSEEN'; } // Flag change if (isset($entry->flag)) { if (empty($entry->flag) || empty($entry->flag->flagType)) { $params['flags'][] = 'UNFLAGGED'; } elseif (preg_match('/follow\s*up/i', $entry->flag->flagType)) { $params['flags'][] = 'FLAGGED'; } } $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); return $serverId; } /** * Delete an email (or move to Trash) * * @param string $folderId * @param string $serverId * @param ?Syncroton_Model_SyncCollection $collection */ public function deleteEntry($folderId, $serverId, $collection = null) { $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); } // Note: If DeletesAsMoves is not specified in the request, its default is 1 (true). $moveToTrash = !isset($collection->deletesAsMoves) || !empty($collection->deletesAsMoves); $deleted = $this->backend->deleteItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], $moveToTrash); if (!$deleted) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } } /** * 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, ['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($message->folder); $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, [ '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'])) { $params = ['flags' => ['FORWARDED']]; $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); } } /** * 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'])) { $params = ['flags' => ['ANSWERED']]; $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); } } /** * ActiveSync Search handler * * @param Syncroton_Model_StoreRequest $store Search query * * @return Syncroton_Model_StoreResponse Complete Search response */ public function search(Syncroton_Model_StoreRequest $store) { [$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 = []; // @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; } $search->revert(); $uids = $search->get(); foreach ($uids as $idx => $uid) { $uids[$idx] = new Syncroton_Model_StoreResponseResult([ 'longId' => $this->serverId($uid, $folderid), '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 = [$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 = []; if (empty($query) || !is_array($query)) { return []; } 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 (isset($query['and']['freeText']) && strlen($query['and']['freeText'])) { // @FIXME: Should we use TEXT/BODY search? ActiveSync protocol specification says "indexed fields" $search = $query['and']['freeText']; $search_keys = ['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 (!strlen($search_str)) { return []; } $search_str = 'ALL UNDELETED ' . trim($search_str); // @TODO: DeepTraversal if (empty($folders)) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } return [$folders, $search_str]; } /** * Fetches the entry from the backend */ protected function getObject($entryid, $dummy = null) { $message = $this->parseMessageId($entryid); if (empty($message)) { // @TODO: exception? return null; } return $this->backend->getItem($message['folderId'], $this->device->deviceid, $this->modelName, $message['uid']); } /** * Get attachment data from the server. * * @param string $fileReference * * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference) { [$folderid, $uid, $part_id] = explode('::', $fileReference); $message = $this->getObject($fileReference); if (!$message) { - throw new Syncroton_Exception_NotFound('Message not found'); + throw new Syncroton_Exception_NotFound("Message $fileReference not found"); } $part = $message->mime_parts[$part_id]; $body = $message->get_part_body($part_id); return new Syncroton_Model_FileReference([ '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']; } if (!is_string($entryid) || !strpos($entryid, '::')) { return; } // Note: the id might be in a form of ::[::] [$folderid, $uid] = explode('::', $entryid); return [ 'uid' => $uid, 'folderId' => $folderid, ]; } /** * Creates entry ID of the message */ protected function serverId($uid, $folderid) { return $folderid . '::' . $uid; } /** * Returns body of the message in specified format * * @param rcube_message $message * @param bool $html */ 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 * * @param rcube_message $message * @param rcube_message_part $part * @param bool $html */ protected function getMessagePartBody($message, $part, $html = false) { if (empty($part->size) || empty($part->mime_id)) { // TODO: Throw an exception? return ''; } // Check if we have enough memory to handle the message in it $body = $this->message_mem_check($message, $part->size, false); if ($body !== false) { $body = $message->get_part_body($part->mime_id, true); } // message is cached but not exists, or other error if ($body === false) { return ''; } $ctype_secondary = !empty($part->ctype_secondary) ? $part->ctype_secondary : null; if ($html) { if ($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; } } elseif ($ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); } else { // Roundcube >= 1.2 if (class_exists('rcube_text2html')) { $flowed = isset($part->ctype_parameters['format']) && $part->ctype_parameters['format'] == 'flowed'; $delsp = isset($part->ctype_parameters['delsp']) && $part->ctype_parameters['delsp'] == 'yes'; $options = ['flowed' => $flowed, 'wrap' => false, 'delsp' => $delsp]; $text2html = new rcube_text2html($body, false, $options); $body = '' . $text2html->get_html() . ''; } else { $body = '
' . $body . '
'; } } } else { if ($ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); $part->ctype_secondary = 'html'; } if ($ctype_secondary == 'html') { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } else { if ($ctype_secondary == 'plain' && !empty($part->ctype_parameters['format']) && $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); } 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 (isset($line[0]) && $line[0] == '>') { $line = '>' . rtrim($line); } elseif (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_from_message($message) { // Parse the message and find iTip attachments $libcal = libcalendaring::get_instance(); $libcal->mail_message_load(['object' => $message]); $ical_objects = $libcal->get_mail_ical_objects(); // Skip methods we do not support here if (!in_array($ical_objects->method, ['REQUEST', 'CANCEL', 'REPLY'])) { return null; } // We support only one event in the iTip foreach ($ical_objects as $mime_id => $event) { if ($event['_type'] == 'event') { $event['_method'] = $ical_objects->method; $event['_mime_id'] = $ical_objects->mime_id; return $event; } } return null; } /** * 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)) { return $this->get_invitation_event_from_message($message); } return null; } private function mem_check($need) { $mem_limit = (int) parse_bytes(ini_get('memory_limit')); $memory = static::$memory_accumulated; // @phpstan-ignore-next-line return ($mem_limit > 0 && $memory + $need > $mem_limit) ? false : true; } /** * Checks if the message can be processed, depending on its size and * memory_limit, otherwise throws an exception or returns fake body. */ protected function message_mem_check($message, $size, $result = null) { static $memory_rised; // @FIXME: we need up to 5x more memory than the body // Note: Biggest memory multiplication happens in recode_message() // and the Syncroton engine (which also does not support passing bodies // as streams). It also happens when parsing the plain/html text body // in getMessagePartBody() though the footprint there is probably lower. if (!$this->mem_check($size * 5)) { // If we already rised the memory we throw an exception, so the message // will be synchronized in the next run (then we might have enough memory) if ($memory_rised) { throw new Syncroton_Exception_MemoryExhausted(); } $memory_rised = true; $memory_max = 512; // maximum in MB $memory_limit = round(parse_bytes(ini_get('memory_limit')) / 1024 / 1024); // current limit (in MB) $memory_add = round($size * 5 / 1024 / 1024); // how much we need (in MB) $memory_needed = min($memory_limit + $memory_add, $memory_max) . "M"; if ($memory_limit < $memory_max) { $this->logger->debug("Setting memory_limit=$memory_needed"); if (ini_set('memory_limit', $memory_needed) !== false) { // Memory has been rised, check again // @phpstan-ignore-next-line if ($this->mem_check($size * 5)) { return; } } } $this->logger->warn("Not enough memory. Using fake email body."); if ($result !== null) { return $result; } // Let's return a fake message. If we return an empty body Outlook // will not list the message at all. This way user can do something // with the message (flag, delete, move) and see the reason why it's fake // and importantly see its subject, sender, etc. // TODO: Localization? $msg = "This message is too large for ActiveSync."; // $msg .= "See https://kb.kolabenterprise.com/documentation/some-place for more information."; // Get original message headers $headers = $this->storage->get_raw_headers($message->uid); // Build a fake message with original headers, but changed body return kolab_sync_message::fake_message($headers, $msg); } } }