Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
111 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php
index 1aeb12e..fb1d1d1 100644
--- a/lib/kolab_sync_data_calendar.php
+++ b/lib/kolab_sync_data_calendar.php
@@ -1,1166 +1,1355 @@
<?php
/**
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* Calendar (Events) data class for Syncroton
*/
class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar
{
/**
* Mapping from ActiveSync Calendar namespace fields
*/
protected $mapping = array(
'allDayEvent' => 'allday',
'startTime' => 'start', // keep it before endTime here
//'attendees' => 'attendees',
'body' => 'description',
//'bodyTruncated' => 'bodytruncated',
'busyStatus' => 'free_busy',
//'categories' => 'categories',
'dtStamp' => 'changed',
'endTime' => 'end',
//'exceptions' => 'exceptions',
'location' => 'location',
//'meetingStatus' => 'meetingstatus',
//'organizerEmail' => 'organizeremail',
//'organizerName' => 'organizername',
//'recurrence' => 'recurrence',
//'reminder' => 'reminder',
//'responseRequested' => 'responserequested',
//'responseType' => 'responsetype',
'sensitivity' => 'sensitivity',
'subject' => 'title',
//'timezone' => 'timezone',
'uID' => 'uid',
);
/**
* Kolab object type
*
* @var string
*/
protected $modelName = 'event';
/**
* Type of the default folder
*
* @var int
*/
protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR;
/**
* Default container for new entries
*
* @var string
*/
protected $defaultFolder = 'Calendar';
/**
* Type of user created folders
*
* @var int
*/
protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED;
/**
* attendee status
*/
const ATTENDEE_STATUS_UNKNOWN = 0;
const ATTENDEE_STATUS_TENTATIVE = 2;
const ATTENDEE_STATUS_ACCEPTED = 3;
const ATTENDEE_STATUS_DECLINED = 4;
const ATTENDEE_STATUS_NOTRESPONDED = 5;
/**
* attendee types
*/
const ATTENDEE_TYPE_REQUIRED = 1;
const ATTENDEE_TYPE_OPTIONAL = 2;
const ATTENDEE_TYPE_RESOURCE = 3;
/**
* busy status constants
*/
const BUSY_STATUS_FREE = 0;
const BUSY_STATUS_TENTATIVE = 1;
const BUSY_STATUS_BUSY = 2;
const BUSY_STATUS_OUTOFOFFICE = 3;
/**
* Sensitivity values
*/
const SENSITIVITY_NORMAL = 0;
const SENSITIVITY_PERSONAL = 1;
const SENSITIVITY_PRIVATE = 2;
const SENSITIVITY_CONFIDENTIAL = 3;
+ /**
+ * Internal iTip states
+ */
+ const ITIP_ACCEPTED = 'ACCEPTED';
+ const ITIP_DECLINED = 'DECLINED';
+ const ITIP_TENTATIVE = 'TENTATIVE';
+ const ITIP_CANCELLED = 'CANCELLED';
+
const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP';
const KEY_REPLYTIME = 'x-custom.X-ACTIVESYNC-REPLYTIME';
/**
* Mapping of attendee status
*
* @var array
*/
protected $attendeeStatusMap = array(
'UNKNOWN' => self::ATTENDEE_STATUS_UNKNOWN,
'TENTATIVE' => self::ATTENDEE_STATUS_TENTATIVE,
'ACCEPTED' => self::ATTENDEE_STATUS_ACCEPTED,
'DECLINED' => self::ATTENDEE_STATUS_DECLINED,
'DELEGATED' => self::ATTENDEE_STATUS_UNKNOWN,
'NEEDS-ACTION' => self::ATTENDEE_STATUS_NOTRESPONDED,
);
/**
* Mapping of attendee type
*
* NOTE: recurrences need extra handling!
* @var array
*/
protected $attendeeTypeMap = array(
'REQ-PARTICIPANT' => self::ATTENDEE_TYPE_REQUIRED,
'OPT-PARTICIPANT' => self::ATTENDEE_TYPE_OPTIONAL,
// 'NON-PARTICIPANT' => self::ATTENDEE_TYPE_RESOURCE,
// 'CHAIR' => self::ATTENDEE_TYPE_RESOURCE,
);
/**
* Mapping of busy status
*
* @var array
*/
protected $busyStatusMap = array(
'free' => self::BUSY_STATUS_FREE,
'tentative' => self::BUSY_STATUS_TENTATIVE,
'busy' => self::BUSY_STATUS_BUSY,
'outofoffice' => self::BUSY_STATUS_OUTOFOFFICE,
);
/**
* mapping of sensitivity
*
* @var array
*/
protected $sensitivityMap = array(
'public' => self::SENSITIVITY_PERSONAL,
'private' => self::SENSITIVITY_PRIVATE,
'confidential' => self::SENSITIVITY_CONFIDENTIAL,
);
/**
* Appends contact data to xml element
*
* @param Syncroton_Model_SyncCollection $collection Collection data
* @param string $serverId Local entry identifier
* @param bool $as_array Return entry as array
*
* @return array|Syncroton_Model_Event|array Event object
*/
public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false)
{
$event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId);
$config = $this->getFolderConfig($event['folderId']);
$result = array();
$is_outlook = stripos($this->device->devicetype, 'outlook') !== false;
$is_android = stripos($this->device->devicetype, 'android') !== false;
// 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
$result['timezone'] = kolab_sync_timezone_converter::encodeTimezoneFromDate($event['start']);
// Calendar namespace fields
foreach ($this->mapping as $key => $name) {
$value = $this->getKolabDataItem($event, $name);
switch ($name) {
case 'changed':
case 'end':
case 'start':
// For all-day events Kolab uses different times
// At least Android doesn't display such event as all-day event
if ($value && is_a($value, 'DateTime')) {
$date = clone $value;
if (!empty($event['allday'])) {
// need this for self::date_from_kolab()
$date->_dateonly = false; // @phpstan-ignore-line
if ($name == 'start') {
$date->setTime(0, 0, 0);
}
else if ($name == 'end') {
$date->setTime(0, 0, 0);
$date->modify('+1 day');
}
}
// set this date for use in recurrence exceptions handling
if ($name == 'start') {
$event['_start'] = $date;
}
$value = self::date_from_kolab($date);
}
break;
case 'sensitivity':
if (!empty($value)) {
$value = intval($this->sensitivityMap[$value]);
}
break;
case 'free_busy':
if (!empty($value)) {
$value = $this->busyStatusMap[$value];
}
break;
case 'description':
$value = $this->body_from_kolab($value, $collection);
break;
}
// Ignore empty values (but not integer 0)
if ((empty($value) || is_array($value)) && $value !== 0) {
continue;
}
$result[$key] = $value;
}
// Event reminder time
if (!empty($config['ALARMS'])) {
$result['reminder'] = $this->from_kolab_alarm($event);
}
$result['categories'] = array();
$result['attendees'] = array();
// Categories, Roundcube Calendar plugin supports only one category at a time
if (!empty($event['categories'])) {
$result['categories'] = (array) $event['categories'];
}
// Organizer
if (!empty($event['attendees'])) {
foreach ($event['attendees'] as $idx => $attendee) {
if ($attendee['role'] == 'ORGANIZER') {
if (!empty($attendee['name'])) {
$result['organizerName'] = $attendee['name'];
}
if (!empty($attendee['email'])) {
$result['organizerEmail'] = $attendee['email'];
}
unset($event['attendees'][$idx]);
break;
}
}
}
$resp_type = self::ATTENDEE_STATUS_UNKNOWN;
$user_rsvp = false;
// Attendees
if (!empty($event['attendees'])) {
$user_emails = $this->user_emails();
foreach ($event['attendees'] as $idx => $attendee) {
if (empty($attendee['email'])) {
// In Activesync email is required
continue;
}
$email = $attendee['email'];
$att = [
'email' => $email,
'name' => !empty($attendee['name']) ? $attendee['name'] : $email,
];
$type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null;
$status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null;
if ($this->asversion >= 12) {
if (isset($attendee['cutype']) && strtolower($attendee['cutype']) == 'resource') {
$att['attendeeType'] = self::ATTENDEE_TYPE_RESOURCE;
} else {
$att['attendeeType'] = $type ?: self::ATTENDEE_TYPE_REQUIRED;
}
$att['attendeeStatus'] = $status ?: self::ATTENDEE_STATUS_UNKNOWN;
}
if ($email && in_array_nocase($email, $user_emails)) {
$user_rsvp = !empty($attendee['rsvp']);
$resp_type = $status ?: self::ATTENDEE_STATUS_UNKNOWN;
// Synchronize the attendee status to the event status to get the same behaviour as outlook.
if (($is_outlook || $is_android )&& isset($attendee['status'])) {
if ($attendee['status'] == 'ACCEPTED') {
$result['busyStatus'] = self::BUSY_STATUS_BUSY;
}
if ($attendee['status'] == 'TENTATIVE') {
$result['busyStatus'] = self::BUSY_STATUS_TENTATIVE;
}
}
}
$result['attendees'][] = new Syncroton_Model_EventAttendee($att);
}
}
// Event meeting status
$this->meeting_status_from_kolab($event, $result);
// Recurrence (and exceptions)
$this->recurrence_from_kolab($collection, $event, $result);
// RSVP status
$result['responseRequested'] = $result['meetingStatus'] == 3 && $user_rsvp ? 1 : 0;
$result['responseType'] = $result['meetingStatus'] == 3 ? $resp_type : null;
// Appointment Reply Time (without it Outlook displays e.g. "Accepted on None")
if ($resp_type != self::ATTENDEE_STATUS_UNKNOWN) {
if ($reply_time = $this->getKolabDataItem($event, self::KEY_REPLYTIME)) {
$result['appointmentReplyTime'] = new DateTime($reply_time, new DateTimeZone('UTC'));
} elseif (!empty($event['changed'])) {
$reply_time = clone $event['changed'];
$reply_time->setTimezone(new DateTimeZone('UTC'));
$result['appointmentReplyTime'] = $reply_time;
}
}
return $as_array ? $result : new Syncroton_Model_Event($result);
}
/**
* Convert an event from xml to libkolab array
*
* @param Syncroton_Model_Event|Syncroton_Model_EventException $data Event or event exception to convert
* @param string $folderid Folder identifier
* @param array $entry Existing entry
* @param DateTimeZone $timezone Timezone of the event
*
* @return array
*/
public function toKolab($data, $folderid, $entry = null, $timezone = null)
{
if (empty($entry) && !empty($data->uID)) {
// If we don't have an existing event (not a modification) we nevertheless check for conflicts.
// This is necessary so we don't overwrite the server-side copy in case the client did not have it available
// when generating an Add command.
try {
$entry = $this->getObject($folderid, $data->uID);
if ($entry) {
$this->logger->debug('Found and existing event for UID: ' . $data->uID);
}
} catch (Exception $e) {
// uID is not available on exceptions, so we guard for that and silently ignore.
}
}
$config = $this->getFolderConfig($entry ? $entry['folderId'] : $folderid);
$event = !empty($entry) ? $entry : [];
$is_exception = $data instanceof Syncroton_Model_EventException;
$dummy_tz = str_repeat('A', 230) . '==';
$is_outlook = stripos($this->device->devicetype, 'outlook') !== false;
$is_android = stripos($this->device->devicetype, 'android') !== false;
// check data validity (of a new event)
if (empty($event)) {
$this->check_event($data);
}
if (!empty($event['start']) && ($event['start'] instanceof DateTime)) {
$old_timezone = $event['start']->getTimezone();
}
// Timezone
if (!$timezone && isset($data->timezone) && $data->timezone != $dummy_tz) {
$tzc = kolab_sync_timezone_converter::getInstance();
$expected = !empty($old_timezone) ? $old_timezone : kolab_format::$timezone;
try {
$timezone = $tzc->getTimezone($data->timezone, $expected->getName());
$timezone = new DateTimeZone($timezone);
}
catch (Exception $e) {
$this->logger->warn('Failed to convert the timezone information. UID: ' . $event['uid'] . 'Timezone: ' . $data->timezone);
$timezone = null;
}
}
if (empty($timezone)) {
$timezone = !empty($old_timezone) ? $old_timezone : new DateTimeZone('UTC');
}
$event['allday'] = 0;
// Calendar namespace fields
foreach ($this->mapping as $key => $name) {
// skip UID field, unsupported in event exceptions
// we need to do this here, because the next line (data getter) will throw an exception
if ($is_exception && $key == 'uID') {
continue;
}
$value = $data->$key;
// Skip ghosted (unset) properties, (but make sure 'changed' timestamp is reset)
if ($value === null && $name != 'changed') {
continue;
}
switch ($name) {
case 'changed':
$value = null;
break;
case 'end':
case 'start':
if ($timezone && $value) {
$value->setTimezone($timezone);
}
if ($value && $data->allDayEvent) {
$value->_dateonly = true;
// In ActiveSync all-day event ends on 00:00:00 next day
// In Kolab we just ignore the time spec.
if ($name == 'end') {
$diff = date_diff($event['start'], $value);
$value = clone $event['start'];
if ($diff->days > 1) {
$value->add(new DateInterval('P' . ($diff->days - 1) . 'D'));
}
}
}
break;
case 'sensitivity':
$map = array_flip($this->sensitivityMap);
$value = isset($map[$value]) ? $map[$value] : null;
break;
case 'free_busy':
// Outlook sets the busy state to the attendance state, and we don't want to change the event state based on that.
// Outlook doesn't have the concept of an event state, so we just ignore this.
if ($is_outlook || $is_android) {
continue 2;
}
$map = array_flip($this->busyStatusMap);
$value = isset($map[$value]) ? $map[$value] : null;
break;
case 'description':
$value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT);
// If description isn't specified keep old description
if ($value === null) {
continue 2;
}
break;
}
$this->setKolabDataItem($event, $name, $value);
}
// Try to fix allday events from Android
// It doesn't set all-day flag but the period is a whole day
if (empty($event['allday']) && !empty($event['end']) && !empty($event['start'])) {
$interval = @date_diff($event['start'], $event['end']);
if ($interval && $interval->format('%y%m%d%h%i%s') === '001000') {
$event['allday'] = 1;
$event['end'] = clone $event['start'];
}
}
// Reminder
// @TODO: should alarms be used when importing event from phone?
if (!empty($config['ALARMS'])) {
$event['valarms'] = $this->to_kolab_alarm($data->reminder, $event);
}
$attendees = array();
$categories = array();
// Categories
if (isset($data->categories)) {
foreach ($data->categories as $category) {
$categories[] = $category;
}
}
// Organizer
if (!$is_exception) {
// Organizer specified
if ($organizer_email = $data->organizerEmail) {
$attendees[] = array(
'role' => 'ORGANIZER',
'name' => $data->organizerName,
'email' => $organizer_email,
);
} else if (!empty($event['attendees'])) {
// Organizer not specified, use one from the original event if that's an update
foreach ($event['attendees'] as $idx => $attendee) {
if (!empty($attendee['email']) && !empty($attendee['role']) && $attendee['role'] == 'ORGANIZER') {
$organizer_email = $attendee['email'];
$attendees[] = array(
'role' => 'ORGANIZER',
'name' => $attendee['name'] ?? '',
'email' => $organizer_email,
);
}
}
}
}
// Attendees
// Whenever Outlook sends dummy timezone it is an event where the user is an attendee.
// In these cases Attendees element is bogus: contains invalid status and does not
// contain all attendees. We have to ignore it.
if ($is_outlook && !$is_exception && $data->timezone === $dummy_tz) {
$this->logger->debug('Dummy outlook update detected, ignoring attendee changes.');
$attendees = $entry['attendees'];
}
else if (isset($data->attendees)) {
foreach ($data->attendees as $attendee) {
if (!empty($organizer_email) && $attendee->email && !strcasecmp($attendee->email, $organizer_email)) {
// skip the organizer
continue;
}
$role = false;
if (isset($attendee->attendeeType)) {
$role = array_search($attendee->attendeeType, $this->attendeeTypeMap);
}
if ($role === false) {
$role = array_search(self::ATTENDEE_TYPE_REQUIRED, $this->attendeeTypeMap);
}
$_attendee = array(
'role' => $role,
'name' => $attendee->name != $attendee->email ? $attendee->name : '',
'email' => $attendee->email,
);
if (isset($attendee->attendeeType) && $attendee->attendeeType == self::ATTENDEE_TYPE_RESOURCE) {
$_attendee['cutype'] = 'RESOURCE';
}
if (isset($attendee->attendeeStatus)) {
$_attendee['status'] = $attendee->attendeeStatus ? array_search($attendee->attendeeStatus, $this->attendeeStatusMap) : null;
if (!$_attendee['status']) {
$_attendee['status'] = 'NEEDS-ACTION';
$_attendee['rsvp'] = true;
}
}
else if (!empty($event['attendees']) && !empty($attendee->email)) {
// copy the old attendee status
foreach ($event['attendees'] as $old_attendee) {
if ($old_attendee['email'] == $_attendee['email'] && isset($old_attendee['status'])) {
$_attendee['status'] = $old_attendee['status'];
$_attendee['rsvp'] = $old_attendee['rsvp'];
break;
}
}
}
$attendees[] = $_attendee;
}
}
// Outlook does not send the correct attendee status when changing between accepted and tentative, but it toggles the busyStatus.
if ($is_outlook || $is_android) {
$status = null;
if ($data->busyStatus == self::BUSY_STATUS_BUSY) {
$status = "ACCEPTED";
} else if ($data->busyStatus == self::BUSY_STATUS_TENTATIVE) {
$status = "TENTATIVE";
}
if ($status) {
$this->logger->debug("Updating our attendee status based on the busy status to {$status}.");
$emails = $this->user_emails();
$this->find_and_update_attendee_status($attendees, $status, $emails);
}
}
if (!$is_exception) {
// Make sure the event has the organizer set
if (!$organizer_email && ($identity = kolab_sync::get_instance()->user->get_identity())) {
$attendees[] = array(
'role' => 'ORGANIZER',
'name' => $identity['name'],
'email' => $identity['email'],
);
}
// recurrence (and exceptions)
$event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone);
}
$event['attendees'] = $attendees;
$event['categories'] = $categories;
$event['exceptions'] = isset($event['recurrence']['EXCEPTIONS']) ? $event['recurrence']['EXCEPTIONS'] : array();
// Bump SEQUENCE number on update (Outlook only).
// It's been confirmed that any change of the event that has attendees specified
// bumps SEQUENCE number of the event (we can see this in sent iTips).
// Unfortunately Outlook also sends an update when no SEQUENCE bump
// is needed, e.g. when updating attendee status.
// We try our best to bump the SEQUENCE only when expected
// @phpstan-ignore-next-line
if (!empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) {
if ($last_update = $this->getKolabDataItem($event, self::KEY_DTSTAMP)) {
$last_update = new DateTime($last_update);
}
if (!empty($data->dtStamp) && $data->dtStamp != $last_update) {
if ($this->has_significant_changes($event, $entry)) {
$event['sequence']++;
$this->logger->debug('Found significant changes in the updated event. Bumping SEQUENCE to ' . $event['sequence']);
}
}
}
// Because we use last event modification time above, we make sure
// the event modification time is not (re)set by the server,
// we use the original Outlook's timestamp.
if ($is_outlook && !empty($data->dtStamp)) {
$this->setKolabDataItem($event, self::KEY_DTSTAMP, $data->dtStamp->format(DateTime::ATOM));
}
// This prevents kolab_format code to bump the sequence when not needed
if (!isset($event['sequence'])) {
$event['sequence'] = 0;
}
return $event;
}
/**
* Set attendee status for meeting
*
* @param Syncroton_Model_MeetingResponse $request The meeting response
*
* @return string ID of new calendar entry
*/
public function setAttendeeStatus(Syncroton_Model_MeetingResponse $request)
{
$status_map = array(
1 => 'ACCEPTED',
2 => 'TENTATIVE',
3 => 'DECLINED',
);
$status = $status_map[$request->userResponse] ?? null;
if (empty($status)) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
// extract event from the invitation
list($event, $existing) = $this->get_event_from_invitation($request);
/*
switch ($status) {
case 'ACCEPTED': $event['free_busy'] = 'busy'; break;
case 'TENTATIVE': $event['free_busy'] = 'tentative'; break;
case 'DECLINED': $event['free_busy'] = 'free'; break;
}
*/
// Store response timestamp for further use
$reply_time = new DateTime('now', new DateTimeZone('UTC'));
$this->setKolabDataItem($event, self::KEY_REPLYTIME, $reply_time->format('Ymd\THis\Z'));
// Update/Save the event
if (empty($existing)) {
$folderId = $this->save_event($event, $status);
// Create SyncState for the new event, so it is not synced twice
if ($folderId) {
try {
$syncBackend = Syncroton_Registry::getSyncStateBackend();
$folderBackend = Syncroton_Registry::getFolderBackend();
$contentBackend = Syncroton_Registry::getContentStateBackend();
$syncFolder = $folderBackend->getFolder($this->device->id, $folderId);
$syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id);
$contentBackend->create(new Syncroton_Model_Content(array(
'device_id' => $this->device->id,
'folder_id' => $syncFolder->id,
'contentid' => $this->serverId($event['uid'], $folderId),
'creation_time' => $syncState->lastsync,
'creation_synckey' => $syncState->counter,
)));
}
catch (Exception $e) {
// ignore
}
}
}
else {
$folderId = $this->update_event($event, $existing, $status, $request->instanceId);
}
if (!$folderId) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
// TODO: ActiveSync version >= 16, send the iTip response.
if (isset($request->sendResponse)) {
// SendResponse can contain Body to use as email body (can be empty)
// TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime.
}
// FIXME: We should not return an UID when status=DECLINED
// as it's expected by the specification. Server
// should delete an event in such a case, but we
// keep the event copy with appropriate attendee status instead.
return $this->serverId($event['uid'], $folderId);
}
+ /**
+ * Process an event from an iTip message - update the event in the recipient's calendar
+ *
+ * @param array $event Event data from the iTip
+ *
+ * @return string|null Attendee status from the iTip (self::ITIP_* constant value)
+ */
+ public function processItipReply($event)
+ {
+ // FIXME: This does not prevent from spoofing, i.e. an iTip message
+ // could be sent by anyone impersonating an organizer or attendee
+
+ // FIXME: This will not work with Kolab delegation, as we do look
+ // for the event instance in personal folders only (for now)
+ // We also do not use SENT-BY,DELEGATED-TO,DELEGATED-FROM here at all.
+
+ // FIXME: This is potential performance problem - we update an event
+ // whenever we sync an email message. User can have multiple AC clients
+ // or many iTip messages in INBOX. Should we remember which email was
+ // already processed?
+
+ // FIXME: Should we check SEQUENCE or something else to prevent
+ // overwriting the attendee status with outdated status (on REPLY)?
+
+ // Here we're handling CANCEL message, find the event (or occurrence) and remove it
+ if ($event['_method'] == 'CANCEL') {
+ // TODO: Performance: When we're going to delete the event we don't have to fetch it,
+ // we just need to find that it exists and in which folder.
+
+ if ($existing = $this->find_event_by_uid($event['uid'])) {
+ // Note: Normally we'd just set the event status to canceled, but
+ // ActiveSync clients do not understand that, we have to delete it
+
+ if (!empty($event['recurrence_date'])) {
+ // A single recurring event occurrence
+ $rec_day = $event['recurrence_date']->format('Ymd');
+ // Remove the matching RDATE entry
+ if (!empty($existing['recurrence']['RDATE'])) {
+ foreach ($existing['recurrence']['RDATE'] as $j => $rdate) {
+ if ($rdate->format('Ymd') == $rec_day) {
+ unset($existing['recurrence']['RDATE'][$j]);
+ break;
+ }
+ }
+ }
+
+ // Check EXDATE list, maybe already cancelled
+ if (!empty($existing['recurrence']['EXDATE'])) {
+ foreach ($existing['recurrence']['EXDATE'] as $j => $exdate) {
+ if ($exdate->format('Ymd') == $rec_day) {
+ return self::ITIP_CANCELLED; // skip update
+ }
+ }
+ } else {
+ $existing['recurrence']['EXDATE'] = [];
+ }
+
+ if (!isset($existing['exceptions'])) {
+ $existing['exceptions'] = [];
+ }
+
+ if (!empty($existing['exceptions'])) {
+ foreach ($existing['exceptions'] as $i => $exception) {
+ if (libcalendaring::is_recurrence_exception($event, $exception)) {
+ unset($existing['exceptions'][$i]);
+ }
+ }
+ }
+
+ // Add an exception to the master event
+ $existing['recurrence']['EXDATE'][] = $event['recurrence_date'];
+
+ // TODO: Handle errors
+ $this->save_event($existing, null);
+ }
+ else {
+ $folder = $this->getFolderObject($existing['_mailbox']);
+ if ($folder && $folder->valid) {
+ // TODO: Handle errors
+ $folder->delete($event['uid']);
+ }
+ }
+ }
+
+ return self::ITIP_CANCELLED;
+ }
+
+ // Here we're handling REPLY message
+ if (empty($event['attendees']) || $event['_method'] != 'REPLY') {
+ return null;
+ }
+
+ $attendeeStatus = null;
+ $attendeeEmail = null;
+
+ // Get the attendee/status
+ 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']);
+ $attendeeEmail = $attendee['email'];
+ break;
+ }
+ }
+ }
+
+ // Find the event (or occurrence) and update it
+ if ($attendeeStatus && ($existing = $this->find_event_by_uid($event['uid']))) {
+ // TODO: We should probably check the SEQUENCE to not reset status to an outdated value
+
+ if (!empty($event['recurrence_date'])) {
+ // A single recurring event occurrence
+ // Find the exception entry, it should exist, if not ignore
+ if (!empty($existing['exceptions'])) {
+ foreach ($existing['exceptions'] as $i => $exception) {
+ if (!empty($exception['attendees']) && libcalendaring::is_recurrence_exception($event, $exception)) {
+ $attendees = &$existing['exceptions'][$i]['attendees'];
+ break;
+ }
+ }
+ }
+ }
+ else if (!empty($existing['attendees'])) {
+ $attendees = &$existing['attendees'];
+ }
+
+ if (isset($attendees)) {
+ $found = $this->find_and_update_attendee_status($attendees, $attendeeStatus, [$attendeeEmail], $changed);
+ if ($found && $changed) {
+ // TODO: error handling
+ $this->save_event($existing, null);
+ }
+ }
+ }
+
+ return $attendeeStatus;
+ }
+
/**
* Get an event from the invitation email or calendar folder
*/
protected function get_event_from_invitation(Syncroton_Model_MeetingResponse $request)
{
// Limitation: LongId might be used instead of RequestId, this is not supported
if ($request->requestId) {
$mail_class = new kolab_sync_data_email($this->device, $this->syncTimeStamp);
// Event from an invitation email
if ($event = $mail_class->get_invitation_event($request->requestId)) {
// find the event in calendar
$existing = $this->find_event_by_uid($event['uid']);
return array($event, $existing);
}
// Event from calendar folder
if ($event = $this->getObject($request->collectionId, $request->requestId)) {
return array($event, $event);
}
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST);
}
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
/**
* Find the Kolab event in any (of subscribed personal calendars) folder
*/
protected function find_event_by_uid($uid)
{
if (empty($uid)) {
return;
}
// TODO: should we check every existing event folder even if not subscribed for sync?
if ($folders = $this->listFolders()) {
foreach ($folders as $_folder) {
$folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName);
if ($folder
&& $folder->get_namespace() == 'personal'
&& ($result = $this->backend->getItem($_folder['serverId'], $this->device->deviceid, $this->modelName, $uid))
) {
$result['folderId'] = $_folder['serverId'];
return $result;
}
}
}
}
/**
* Wrapper to update an event object
*/
protected function update_event($event, $old, $status, $instanceId = null)
{
// TODO: instanceId - DateTime - of the exception to be processed, if not set process all occurrences
if ($instanceId) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST);
}
- if ($event['free_busy']) {
- $old['free_busy'] = $event['free_busy'];
+ // A single recurring event occurrence
+ if (!empty($event['recurrence_date'])) {
+ $event['recurrence'] = [];
+
+ if ($status) {
+ $this->update_attendee_status($event, $status);
+ $status = null;
+ }
+
+ if (!isset($old['exceptions'])) {
+ $old['exceptions'] = [];
+ }
+
+ $existing = false;
+ foreach ($old['exceptions'] as $i => $exception) {
+ if (libcalendaring::is_recurrence_exception($event, $exception)) {
+ $old['exceptions'][$i] = $event;
+ $existing = true;
+ }
+ }
+
+ // TODO: In case organizer first cancelled an occurrence and then invited
+ // an attendee to the same date, and attendee accepts, we should remove EXDATE entry.
+ // FIXME: We have to check with ActiveSync clients whether it is better
+ // to have an exception with DECLINED attendee status, or an EXDATE entry
+
+ if (!$existing) {
+ $old['exceptions'][] = $event;
+ }
+ }
+ // A main event update
+ else if (isset($event['sequence']) && $event['sequence'] > $old['sequence']) {
+ // FIXME: Can we be smarter here? Should we update everything? What about e.g. new attendees?
+ // And do we need to check the sequence?
+ $props = ['start', 'end', 'title', 'description', 'location', 'free_busy'];
+
+ foreach ($props as $prop) {
+ if (isset($event[$prop])) {
+ $old[$prop] = $event[$prop];
+ }
+ }
+
+ // Copy new custom properties
+ if (!empty($event['x-custom'])) {
+ foreach ($event['x-custom'] as $key => $val) {
+ $old['x-custom'][$key] = $val;
+ }
+ }
}
// Updating an existing event is most-likely a response
// to an iTip request with bumped SEQUENCE
$old['sequence'] = ($old['sequence'] ?? 0) + 1;
- // Copy new custom properties
- if (!empty($event['x-custom'])) {
- foreach ($event['x-custom'] as $key => $val) {
- $old['x-custom'][$key] = $val;
- }
- }
-
// Update the event
return $this->save_event($old, $status);
}
/**
* Save the Kolab event (create if not exist)
* If an event does not exist it will be created in the default folder
*/
protected function save_event(&$event, $status = null)
{
$first = null;
$default = null;
if (!isset($event['folderId'])) {
// Find the folder to which we'll save the event
if ($folders = $this->listFolders()) {
foreach ($folders as $_folder) {
$folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName);
if ($folder && $folder->get_namespace() == 'personal') {
if ($_folder['type'] == 8) {
$default = $_folder['serverId'];
break;
}
if (!$first) {
$first = $_folder['serverId'];
}
}
}
}
// TODO: what if the user has no subscribed event folders for this device
// should we use any existing event folder even if not subscribed for sync?
}
if ($status) {
$this->update_attendee_status($event, $status);
}
// TODO: Free/busy trigger?
$old_uid = isset($event['folderId']) ? $event['uid'] : null;
$folder_id = $event['folderId'] ?? ($default ?? $first);
$folder = $this->backend->getFolder($folder_id, $this->device->deviceid, $this->modelName);
if (!empty($folder) && $folder->valid && $folder->save($event, $this->modelName, $old_uid)) {
return $folder_id;
}
return false;
}
/**
* Update the attendee status of the user matching $emails
*/
- protected function find_and_update_attendee_status(&$attendees, $status, $emails)
+ protected function find_and_update_attendee_status(&$attendees, $status, $emails, &$changed = false)
{
$found = false;
foreach ((array) $attendees as $i => $attendee) {
if (!empty($attendee['email'])
&& (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER')
&& in_array_nocase($attendee['email'], $emails)
) {
+ $changed = $changed || ($status != ($attendee['status'] ?? ''));
$attendees[$i]['status'] = $status;
$attendees[$i]['rsvp'] = false;
$this->logger->debug('Updating existing attendee: ' . $attendee['email'] . ' status: ' . $status);
$found = true;
}
}
return $found;
}
/**
* Update the attendee status of the user
*/
protected function update_attendee_status(&$event, $status)
{
$emails = $this->user_emails();
if (!$this->find_and_update_attendee_status($event['attendees'], $status, $emails)) {
$this->logger->debug('Adding new attendee ' . $emails[0] . ' status: ' . $status);
// Add the user to the attendees list
$event['attendees'][] = array(
'role' => 'OPT-PARTICIPANT',
'name' => '',
'email' => $emails[0],
'status' => $status,
'rsvp' => false,
);
}
}
/**
* 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 = array(array('type', '=', $this->modelName));
switch ($filter_type) {
case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK:
$mod = '-2 weeks';
break;
case Syncroton_Command_Sync::FILTER_1_MONTH_BACK:
$mod = '-1 month';
break;
case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK:
$mod = '-3 months';
break;
case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK:
$mod = '-6 months';
break;
}
if (!empty($mod)) {
$dt = new DateTime('now', new DateTimeZone('UTC'));
$dt->modify($mod);
$filter[] = array('dtend', '>', $dt);
}
return $filter;
}
/**
* Set MeetingStatus according to event data
*/
protected function meeting_status_from_kolab($event, &$result)
{
// 0 - The event is an appointment, which has no attendees.
// 1 - The event is a meeting and the user is the meeting organizer.
// 3 - This event is a meeting, and the user is not the meeting organizer.
// 5 - The meeting has been canceled and the user was the meeting organizer.
// 7 - The meeting has been canceled. The user was not the meeting organizer.
$status = 0;
if (!empty($event['attendees'])) {
// Find out if the user is an organizer
// TODO: Delegation/aliases support
$user_emails = $this->user_emails();
$is_organizer = false;
if ($event['organizer'] && $event['organizer']['email']) {
$is_organizer = in_array_nocase($event['organizer']['email'], $user_emails);
}
if ($event['status'] == 'CANCELLED') {
$status = $is_organizer ? 5 : 7;
}
else {
$status = $is_organizer ? 1 : 3;
}
}
$result['meetingStatus'] = $status;
}
/**
* Converts libkolab alarms spec. into a number of minutes
*/
protected function from_kolab_alarm($event)
{
if (isset($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) {
$value = $alarm['trigger'];
break;
}
}
}
if (!empty($value) && $value instanceof DateTime) {
if (!empty($event['start']) && ($interval = $event['start']->diff($value))) {
if ($interval->invert && !$interval->m && !$interval->y) {
return intval(round($interval->s/60) + $interval->i + $interval->h * 60 + $interval->d * 60 * 24);
}
}
}
else if (!empty($value) && preg_match('/^([-+]*)[PT]*([0-9]+)([WDHMS])$/', $value, $matches)) {
$value = intval($matches[2]);
if ($value && $matches[1] != '-') {
return null;
}
switch ($matches[3]) {
case 'S': $value = intval(round($value/60)); break;
case 'H': $value *= 60; break;
case 'D': $value *= 24 * 60; break;
case 'W': $value *= 7 * 24 * 60; break;
}
return $value;
}
}
/**
* Converts ActiveSync reminder into libkolab alarms spec.
*/
protected function to_kolab_alarm($value, $event)
{
if ($value === null || $value === '') {
return isset($event['valarms']) ? (array) $event['valarms'] : array();
}
$valarms = array();
$unsupported = array();
if (!empty($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (empty($current) && in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) {
$current = $alarm;
}
else {
$unsupported[] = $alarm;
}
}
}
$valarms[] = array(
'action' => !empty($current['action']) ? $current['action'] : 'DISPLAY',
'description' => !empty($current['description']) ? $current['description'] : '',
'trigger' => sprintf('-PT%dM', $value),
);
if (!empty($unsupported)) {
$valarms = array_merge($valarms, $unsupported);
}
return $valarms;
}
/**
* Sanity checks on event input
*
* @param Syncroton_Model_IEntry &$entry Entry object
*
* @throws Syncroton_Exception_Status_Sync
*/
protected function check_event(Syncroton_Model_IEntry &$entry)
{
// https://msdn.microsoft.com/en-us/library/jj194434(v=exchg.80).aspx
$now = new DateTime('now');
$rounded = new DateTime('now');
$min = (int) $rounded->format('i');
$add = $min > 30 ? (60 - $min) : (30 - $min);
$rounded->add(new DateInterval('PT' . $add . 'M'));
if (empty($entry->startTime) && empty($entry->endTime)) {
// use current time rounded to 30 minutes
$end = clone $rounded;
$end->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M'));
$entry->startTime = $rounded;
$entry->endTime = $end;
}
else if (empty($entry->startTime)) {
if ($entry->endTime < $now || $entry->endTime < $rounded) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM);
}
$entry->startTime = $rounded;
}
else if (empty($entry->endTime)) {
if ($entry->startTime < $now) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM);
}
$rounded->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M'));
$entry->endTime = $rounded;
}
}
/**
* Check if the new event version has any significant changes
*/
protected function has_significant_changes($event, $old)
{
// Calendar namespace fields
foreach (array('allday', 'start', 'end', 'location', 'recurrence') as $key) {
if ((isset($event[$key]) ? $event[$key] : null) != (isset($old[$key]) ? $old[$key] : null)) {
// Comparing recurrence is tricky as there can be differences in default
// value handling. Let's try to handle most common cases
if ($key == 'recurrence' && $this->fixed_recurrence($event) == $this->fixed_recurrence($old)) {
continue;
}
return true;
}
}
if (count($event['attendees']) != count($old['attendees'])) {
return true;
}
foreach ($event['attendees'] as $idx => $attendee) {
$old_attendee = $old['attendees'][$idx];
if ($old_attendee['email'] != $attendee['email']
|| ($attendee['role'] != 'ORGANIZER'
&& $attendee['status'] != $old_attendee['status']
&& $attendee['status'] == 'NEEDS-ACTION')
) {
return true;
}
}
return false;
}
/**
* Unify recurrence spec. for comparison
*/
protected function fixed_recurrence($event)
{
$rec = (array) $event['recurrence'];
// Add BYDAY if not exists
if ($rec['FREQ'] == 'WEEKLY' && empty($rec['BYDAY'])) {
$days = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
$day = $event['start']->format('w');
$rec['BYDAY'] = $days[$day];
}
if (!$rec['INTERVAL']) {
$rec['INTERVAL'] = 1;
}
ksort($rec);
return $rec;
}
}
diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php
index 302b02c..e8801b0 100644
--- a/lib/kolab_sync_data_email.php
+++ b/lib/kolab_sync_data_email.php
@@ -1,1527 +1,1577 @@
<?php
/**
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* 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',
);
static $memory_accumulated = 0;
/**
* 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;
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(array(
'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
}
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) (isset($prefs[$type]['preview']) ? $prefs[$type]['preview'] : 0);
$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
$isTruncated = 0;
if (empty($prefs)) {
$messageBody = '';
$real_length = $headers->size;
$truncateAt = 0;
$body_length = 0;
$isTruncated = 1;
}
else if ($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';
}
else if ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') {
$result['messageClass'] = 'IPM.Note.SMIME';
}
else if ($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 = array();
$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;
}
} else if (!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 $idx => $attendee) {
+ 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,
]);
- // TODO handle other methods
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 {
+ } else { // REPLY or CANCEL
$meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_NORMAL;
+
+ $itip_processing = kolab_sync::get_instance()->config->get('activesync_itip_processing');
+ 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);
+ }
+ else if ($event['_method'] == 'CANCEL') {
+ $attendeeStatus = kolab_sync_data_calendar::ITIP_CANCELLED;
+ }
+ else if (!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;
- $result['meetingRequest'] = new Syncroton_Model_EmailMeetingRequest($meeting);
+ if (empty($skipRequest)) {
+ $result['meetingRequest'] = new Syncroton_Model_EmailMeetingRequest($meeting);
+ }
}
// Categories (Tags)
$result['categories'] = $message->headers->others['categories'] ?? [];
$is_ios = preg_match('/(iphone|ipad)/i', $this->device->devicetype);
// attachments
$attachments = array_merge($message->attachments, $message->inline_parts);
if (!empty($attachments)) {
$result['attachments'] = array();
foreach ($attachments as $attachment) {
$att = array();
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(array(
'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 = 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
*
* @param string $folder_id Folder identifier
*
* @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);
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_IEntry $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';
}
else if (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)
{
$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, 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($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, 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'])) {
$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)
{
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->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 = 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 (!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 = 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 (!strlen($search_str)) {
return array();
}
$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 array($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']);
}
/**
* @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'];
}
if (!is_string($entryid) || !strpos($entryid, '::')) {
return;
}
// Note: the id might be in a form of <folder>::<uid>[::<part_id>]
list($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) || !isset($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 = '<meta http-equiv="Content-Type" content="text/html; charset='.RCUBE_CHARSET.'" />';
// remove old meta tag and add the new one, making sure
// that it is placed in the head
$body = preg_replace('/<meta[^>]+charset=[a-z0-9-_]+[^>]*>/Ui', '', $body);
$body = preg_replace('/(<head[^>]*>)/Ui', '\\1'.$meta, $body, -1, $rcount);
if (!$rcount) {
$body = '<head>' . $meta . '</head>' . $body;
}
}
else if ($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 = array('flowed' => $flowed, 'wrap' => false, 'delsp' => $delsp);
$text2html = new rcube_text2html($body, false, $options);
$body = '<html><body>' . $text2html->get_html() . '</body></html>';
}
else {
$body = '<html><body><pre>' . $body . '</pre></body></html>';
}
}
}
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 <Preview>
*
* @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);
}
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_from_message($message)
{
// 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();
+ // 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
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);
}
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Apr 6, 1:43 AM (4 d, 17 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831869
Default Alt Text
(111 KB)

Event Timeline