diff --git a/lib/ext/Syncroton/Model/AXMLEntry.php b/lib/ext/Syncroton/Model/AXMLEntry.php index 4b60e26..0146c73 100644 --- a/lib/ext/Syncroton/Model/AXMLEntry.php +++ b/lib/ext/Syncroton/Model/AXMLEntry.php @@ -1,342 +1,346 @@ */ /** * abstract class to handle ActiveSync entry * * @package Syncroton * @subpackage Model */ abstract class Syncroton_Model_AXMLEntry extends Syncroton_Model_AEntry implements Syncroton_Model_IXMLEntry { protected $_xmlBaseElement; protected $_properties = array(); protected $_dateTimeFormat = "Y-m-d\TH:i:s.000\Z"; /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::__construct() */ public function __construct($properties = null) { if ($properties instanceof SimpleXMLElement) { $this->setFromSimpleXMLElement($properties); } elseif (is_array($properties)) { $this->setFromArray($properties); } $this->_isDirty = false; } /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::appendXML() */ - public function appendXML(DOMElement $domParrent, Syncroton_Model_IDevice $device) + public function appendXML(DOMElement $domParent, Syncroton_Model_IDevice $device) { - $this->_addXMLNamespaces($domParrent); + $this->_addXMLNamespaces($domParent); foreach ($this->_elements as $elementName => $value) { // skip empty values if ($value === null || $value === '' || (is_array($value) && empty($value))) { continue; } list ($nameSpace, $elementProperties) = $this->_getElementProperties($elementName, $device->acsversion); if ($nameSpace == 'Internal') { continue; } $nameSpace = 'uri:' . $nameSpace; if (isset($elementProperties['childElement'])) { - $element = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); + $element = $domParent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); foreach($value as $subValue) { - $subElement = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementProperties['childElement'])); + $subElement = $domParent->ownerDocument->createElementNS($nameSpace, ucfirst($elementProperties['childElement'])); $this->_appendXMLElement($device, $subElement, $elementProperties, $subValue); $element->appendChild($subElement); } - $domParrent->appendChild($element); + $domParent->appendChild($element); } else if ($elementProperties['type'] == 'container' && !empty($elementProperties['multiple'])) { foreach ($value as $element) { - $container = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); + $container = $domParent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); $element->appendXML($container, $device); - $domParrent->appendChild($container); + $domParent->appendChild($container); } } else if ($elementProperties['type'] == 'none') { if ($value) { - $element = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); - $domParrent->appendChild($element); + $element = $domParent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); + $domParent->appendChild($element); } } else { - $element = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); + $element = $domParent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); $this->_appendXMLElement($device, $element, $elementProperties, $value); - $domParrent->appendChild($element); + $domParent->appendChild($element); } } } /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::getProperties() */ public function getProperties($selectedNamespace = null) { $properties = array(); foreach ($this->_properties as $namespace => $namespaceProperties) { if ($selectedNamespace !== null && $namespace != $selectedNamespace) { continue; } $properties = array_merge($properties, array_keys($namespaceProperties)); } return array_unique($properties); } /** * set properties from SimpleXMLElement object * * @param SimpleXMLElement $xmlCollection * @throws InvalidArgumentException */ public function setFromSimpleXMLElement(SimpleXMLElement $properties) { if (!in_array($properties->getName(), (array) $this->_xmlBaseElement)) { throw new InvalidArgumentException('Unexpected element name: ' . $properties->getName()); } foreach (array_keys($this->_properties) as $namespace) { if ($namespace == 'Internal') { continue; } $this->_parseNamespace($namespace, $properties); } } /** * add needed xml namespaces to DomDocument - * - * @param unknown_type $domParrent + * + * @param DOMElement $domParent */ - protected function _addXMLNamespaces(DOMElement $domParrent) + protected function _addXMLNamespaces(DOMElement $domParent) { foreach (array_keys($this->_properties) as $namespace) { // don't add default namespace again - if ($domParrent->ownerDocument->documentElement->namespaceURI != 'uri:'.$namespace) { - $domParrent->ownerDocument->documentElement->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:'.$namespace, 'uri:'.$namespace); + if ($domParent->ownerDocument->documentElement->namespaceURI != 'uri:'.$namespace) { + $domParent->ownerDocument->documentElement->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:'.$namespace, 'uri:'.$namespace); } } } protected function _appendXMLElement(Syncroton_Model_IDevice $device, DOMElement $element, $elementProperties, $value) { if ($value instanceof Syncroton_Model_IEntry && $elementProperties['type'] === 'container') { $value->appendXML($element, $device); } else { if ($value instanceof DateTime) { $value = $value->format($this->_dateTimeFormat); } elseif (isset($elementProperties['encoding']) && $elementProperties['encoding'] == 'base64') { if (is_resource($value)) { rewind($value); $value = stream_get_contents($value); } $value = base64_encode($value); } if ($elementProperties['type'] == 'byteArray') { $element->setAttributeNS('uri:Syncroton', 'Syncroton:encoding', 'opaque'); // encode to base64; the wbxml encoder will base64_decode it again // this way we can also transport data, which would break the xmlparser otherwise $element->appendChild($element->ownerDocument->createCDATASection(base64_encode($value))); } else if ($elementProperties['type'] == 'double') { $element->appendChild($element->ownerDocument->createTextNode((string) floatval($value))); } else { $value = (string) $value; // strip off any non printable control characters if (!ctype_print($value)) { $value = $this->_removeControlChars($value); } $element->appendChild($element->ownerDocument->createTextNode($this->_enforceUTF8($value))); } } } /** * remove control chars from a string which are not allowed in XML values * * @param string $dirty An input string * @return string Cleaned up string */ protected function _removeControlChars($dirty) { // Replace non-character UTF-8 sequences that cause XML Parser to fail // https://git.kolab.org/T1311 $dirty = str_replace(array("\xEF\xBF\xBE", "\xEF\xBF\xBF"), '', $dirty); // Replace ASCII control-characters return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $dirty); } /** * enforce >valid< utf-8 encoding * * @param string $dirty the string with maybe invalid utf-8 data * @return string string with valid utf-8 */ protected function _enforceUTF8($dirty) { if (function_exists('iconv')) { if (($clean = @iconv('UTF-8', 'UTF-8//IGNORE', $dirty)) !== false) { return $clean; } } if (function_exists('mb_convert_encoding')) { if (($clean = mb_convert_encoding($dirty, 'UTF-8', 'UTF-8')) !== false) { return $clean; } } return $dirty; } /** * * @param unknown_type $element Element name * @param string $version Protocol version * @throws InvalidArgumentException * @return array */ protected function _getElementProperties($element, $version = null) { foreach ($this->_properties as $namespace => $namespaceProperties) { if (array_key_exists($element, $namespaceProperties)) { $elementProperties = $namespaceProperties[$element]; if ($version) { $supportedSince = isset($elementProperties['supportedSince']) ? $elementProperties['supportedSince'] : '12.0'; $supportedUntil = isset($elementProperties['supportedUntil']) ? $elementProperties['supportedUntil'] : '9999'; if (version_compare($version, $supportedSince, '<') || version_compare($version, $supportedUntil, '>')) { continue; } } return array($namespace, $elementProperties); } } throw new InvalidArgumentException("$element is no valid property of " . get_class($this)); } protected function _parseNamespace($nameSpace, SimpleXMLElement $properties) { // fetch data from the specified namespace $children = $properties->children("uri:$nameSpace"); foreach ($children as $elementName => $xmlElement) { $elementName = lcfirst($elementName); if (!isset($this->_properties[$nameSpace][$elementName])) { continue; } list (, $elementProperties) = $this->_getElementProperties($elementName); switch ($elementProperties['type']) { case 'container': if (!empty($elementProperties['multiple'])) { $property = (array) $this->$elementName; if (isset($elementProperties['class'])) { $property[] = new $elementProperties['class']($xmlElement); } else { $property[] = (string) $xmlElement; } } else if (isset($elementProperties['childElement'])) { $property = array(); $childElement = ucfirst($elementProperties['childElement']); foreach ($xmlElement->$childElement as $subXmlElement) { if (isset($elementProperties['class'])) { $property[] = new $elementProperties['class']($subXmlElement); } else { $property[] = (string) $subXmlElement; } } } else { $subClassName = isset($elementProperties['class']) ? $elementProperties['class'] : get_class($this) . ucfirst($elementName); $property = new $subClassName($xmlElement); } break; case 'datetime': $property = new DateTime((string) $xmlElement, new DateTimeZone('UTC')); break; case 'number': $property = (int) $xmlElement; break; case 'double': $property = (float) $xmlElement; break; default: $property = (string) $xmlElement; break; } if (isset($elementProperties['encoding']) && $elementProperties['encoding'] == 'base64') { $property = base64_decode($property); } $this->$elementName = $property; } } public function &__get($name) { $this->_getElementProperties($name); return $this->_elements[$name]; } public function __set($name, $value) { list ($nameSpace, $properties) = $this->_getElementProperties($name); if ($properties['type'] == 'datetime' && !$value instanceof DateTime) { throw new InvalidArgumentException("value for $name must be an instance of DateTime"); } if (!array_key_exists($name, $this->_elements) || $this->_elements[$name] != $value) { - // Always use location object + // Always use Location object if ($name === 'location' && !($value instanceof Syncroton_Model_Location)) { $value = new Syncroton_Model_Location($value); } + // Always use Attachments + else if ($name === 'attachments' && !($value instanceof Syncroton_Model_Attachments)) { + $value = new Syncroton_Model_Attachments($value); + } $this->_elements[$name] = $value; $this->_isDirty = true; } } } diff --git a/lib/ext/Syncroton/Model/Attachment.php b/lib/ext/Syncroton/Model/Attachment.php new file mode 100644 index 0000000..cd33a13 --- /dev/null +++ b/lib/ext/Syncroton/Model/Attachment.php @@ -0,0 +1,82 @@ + + */ + +/** + * Class to handle ActiveSync Attachment (and Attachments::Delete/Add) + * + * @package Syncroton + * @subpackage Model + * @property string $clientId + * @property string $content + * @property string $contentId + * @property string $contentLocation + * @property string $contentType + * @property string $displayName + * @property int $estimatedDataSize + * @property string $fileReference + * @property bool $isInline + * @property int $method + * @property int $umAttDuration + * @property int $umAttOrder + */ +class Syncroton_Model_Attachment extends Syncroton_Model_AXMLEntry +{ + const METHOD_NORMAL = 1; + const METHOD_EML = 5; + const METHOD_OLE = 6; + + protected $_xmlBaseElement = array('Attachment', 'Add', 'Delete'); + + protected $_properties = array( + 'AirSyncBase' => array( + 'clientId' => array('type' => 'string', 'supportedSince' => '16.0'), + 'content' => array('type' => 'string', 'supportedSince' => '16.0'), // Attachments->Add + 'contentId' => array('type' => 'string'), + 'contentLocation' => array('type' => 'string'), + 'contentType' => array('type' => 'string', 'supportedSince' => '16.0'), // Attachments->Add + 'displayName' => array('type' => 'string'), + 'estimatedDataSize' => array('type' => 'number'), + 'fileReference' => array('type' => 'string'), + 'isInline' => array('type' => 'number'), + 'method' => array('type' => 'number'), + ), + 'Email2' => array( + 'umAttDuration' => array('type' => 'number', 'supportedSince' => '14.0'), + 'umAttOrder' => array('type' => 'number', 'supportedSince' => '14.0'), + ), + ); + + protected $_elementType = 'Attachment'; + + + /** + * Sets object type + * + * @param string $type Object type (one of: Attachment, Add, Delete) + */ + public function setElementType(string $type) + { + if (in_array($type, $this->xmlChildElements)) { + $this->_elementType = $type; + } + } + + /** + * Returns object type + * + * @return string Object type (one of: Attachment, Add, Delete) + */ + public function getElementType() + { + return $this->_elementType; + } +} diff --git a/lib/ext/Syncroton/Model/Attachments.php b/lib/ext/Syncroton/Model/Attachments.php new file mode 100644 index 0000000..ec35fb9 --- /dev/null +++ b/lib/ext/Syncroton/Model/Attachments.php @@ -0,0 +1,81 @@ + + */ + +/** + * Class to handle ActiveSync Attachments element + * + * @package Syncroton + * @subpackage Model + */ +class Syncroton_Model_Attachments extends Syncroton_Model_AXMLEntry +{ + const TYPE_ATTACHMENT = 1; + const TYPE_ADD = 2; + const TYPE_DELETE = 3; + + protected $_xmlBaseElement = 'Attachments'; + protected $_xmlChildElements = array('Attachment', 'Add', 'Delete'); + protected $_nameSpace = 'AirSyncBase'; + + /** + * (non-PHPdoc) + * @see Syncroton_Model_IEntry::setFromArray() + */ + public function setFromArray(array $properties) + { + foreach ($properties as $attachment) { + $this->_elements[] = new Syncroton_Model_Attachment($attachment); + } + } + + /** + * Set properties from SimpleXMLElement object + * + * @param SimpleXMLElement $element + * @throws InvalidArgumentException + */ + public function setFromSimpleXMLElement(SimpleXMLElement $element) + { + if (!in_array($element->getName(), (array) $this->_xmlBaseElement)) { + throw new InvalidArgumentException('Unexpected element name: ' . $element->getName()); + } + + $children = $element->children("uri:" . $this->_nameSpace); + + foreach ($children as $elementName => $xmlElement) { + $elementName = lcfirst($elementName); + if (in_array($elementName, $this->_xmlChildElements)) { + $attachment = new Syncroton_Model_Attachment($xmlElement); + $attachment->setElementType($elementName); + + $this->_elements[] = $attachment; + } + } + } + + /** + * (non-PHPdoc) + * @see Syncroton_Model_IEntry::appendXML() + */ + public function appendXML(DOMElement $domParent, Syncroton_Model_IDevice $device) + { + $nameSpace = 'uri:' . $this->_nameSpace; + $properties = array('type' => 'container'); + + foreach ($this->_elements as $attachment) { + $elementName = $attachment->getElementType(); + $element = $domParent->ownerDocument->createElementNS($nameSpace, $elementName); + $attachment->appendXML($element, $device); + $domParent->appendChild($element); + } + } +} diff --git a/lib/ext/Syncroton/Model/Email.php b/lib/ext/Syncroton/Model/Email.php index 76de3f7..0c46d85 100644 --- a/lib/ext/Syncroton/Model/Email.php +++ b/lib/ext/Syncroton/Model/Email.php @@ -1,129 +1,131 @@ */ /** * class to handle ActiveSync email * * @package Syncroton * @subpackage Model * * @property string $accountId - * @property Syncroton_Model_EmailAttachment[] $attachments - * @property Syncroton_Model_EmailBody $body + * @property Syncroton_Model_Attachments $attachments + * @property Syncroton_Model_EmailBody $body * @property int $busyStatus * @property array $categories * @property string $cc * @property DateTime $completeTime * @property string $contentClass * @property string $contentType * @property string $conversationId * @property string $conversationIndex * @property DateTime $dateReceived * @property bool $disallowNewTimeProposal * @property string $displayTo * @property DateTime $dtStamp * @property DateTime $endTime * @property Syncroton_Model_EmailFlag $flag * @property string $from * @property string $globalObjId - * @property int $importance * @property int $instanceType * @property string $internetCPID + * @property int $importance + * @property bool $isDraft * @property int $lastVerbExecuted * @property DateTime $lastVerbExecutionTime - * @property Syncroton_Model_Location $location + * @property Syncroton_Model_Location $location * @property int $meetingMessageType * @property Syncroton_Model_EmailMeetingRequest $meetingRequest * @property string $messageClass * @property int $nativeBodyType * @property string $organizer * @property int $read * @property int $receivedAsBcc - * @property Syncroton_Model_EventRecurrence[] $recurrences + * @property Syncroton_Model_EventRecurrence[] $recurrences * @property int $reminder * @property string $replyTo * @property int $responseRequested * @property string $sender * @property int $sensitivity * @property DateTime $startTime * @property int $status * @property string $subject * @property string $threadTopic * @property string $timeZone * @property string $to * @property string $umCallerID * @property string $umUserNotes */ class Syncroton_Model_Email extends Syncroton_Model_AXMLEntry { const LASTVERB_UNKNOWN = 0; const LASTVERB_REPLYTOSENDER = 1; const LASTVERB_REPLYTOALL = 2; const LASTVERB_FORWARD = 3; protected $_xmlBaseElement = 'ApplicationData'; protected $_properties = array( 'AirSyncBase' => array( - 'attachments' => array('type' => 'container', 'childElement' => 'attachment', 'class' => 'Syncroton_Model_EmailAttachment'), + 'attachments' => array('type' => 'container', 'class' => 'Syncroton_Model_Attachments'), 'contentType' => array('type' => 'string'), 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody'), 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), 'nativeBodyType' => array('type' => 'number'), ), 'Email' => array( 'busyStatus' => array('type' => 'number'), 'categories' => array('type' => 'container', 'childElement' => 'category', 'supportedSince' => '14.0'), 'cc' => array('type' => 'string'), 'completeTime' => array('type' => 'datetime'), 'contentClass' => array('type' => 'string'), 'dateReceived' => array('type' => 'datetime'), 'disallowNewTimeProposal' => array('type' => 'number'), 'displayTo' => array('type' => 'string'), 'dTStamp' => array('type' => 'datetime'), 'endTime' => array('type' => 'datetime'), 'flag' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailFlag'), 'from' => array('type' => 'string'), 'globalObjId' => array('type' => 'string'), 'importance' => array('type' => 'number'), 'instanceType' => array('type' => 'number'), 'internetCPID' => array('type' => 'string'), 'location' => array('type' => 'string', 'supportedUntil' => '16.0'), 'meetingRequest' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailMeetingRequest'), 'messageClass' => array('type' => 'string'), 'organizer' => array('type' => 'string'), 'read' => array('type' => 'number'), 'recurrences' => array('type' => 'container'), 'reminder' => array('type' => 'number'), 'replyTo' => array('type' => 'string'), 'responseRequested' => array('type' => 'number'), 'sensitivity' => array('type' => 'number'), 'startTime' => array('type' => 'datetime'), 'status' => array('type' => 'number'), 'subject' => array('type' => 'string'), 'threadTopic' => array('type' => 'string'), 'timeZone' => array('type' => 'timezone'), 'to' => array('type' => 'string'), ), 'Email2' => array( 'accountId' => array('type' => 'string', 'supportedSince' => '14.1'), 'conversationId' => array('type' => 'byteArray', 'supportedSince' => '14.0'), 'conversationIndex' => array('type' => 'byteArray', 'supportedSince' => '14.0'), + 'isDraft' => array('type' => 'number', 'supportedSince' => '16.0'), 'lastVerbExecuted' => array('type' => 'number', 'supportedSince' => '14.0'), 'lastVerbExecutionTime' => array('type' => 'datetime', 'supportedSince' => '14.0'), 'meetingMessageType' => array('type' => 'number', 'supportedSince' => '14.1'), 'receivedAsBcc' => array('type' => 'number', 'supportedSince' => '14.0'), 'sender' => array('type' => 'string', 'supportedSince' => '14.0'), 'umCallerID' => array('type' => 'string', 'supportedSince' => '14.0'), 'umUserNotes' => array('type' => 'string', 'supportedSince' => '14.0'), ), ); } diff --git a/lib/ext/Syncroton/Model/EmailAttachment.php b/lib/ext/Syncroton/Model/EmailAttachment.php index bea0c1e..687b04e 100644 --- a/lib/ext/Syncroton/Model/EmailAttachment.php +++ b/lib/ext/Syncroton/Model/EmailAttachment.php @@ -1,43 +1,32 @@ */ /** - * class to handle ActiveSync event + * Class to handle ActiveSync Attachment * * @package Syncroton * @subpackage Model - * @property string class - * @property string collectionId - * @property bool deletesAsMoves - * @property bool getChanges - * @property string syncKey - * @property int windowSize + * @property string $clientId + * @property string $contentId + * @property string $contentLocation + * @property string $displayName + * @property int $estimatedDataSize + * @property string $fileReference + * @property bool $isInline + * @property int $method + * @property int $umAttDuration + * @property int $umAttOrder */ -class Syncroton_Model_EmailAttachment extends Syncroton_Model_AXMLEntry +class Syncroton_Model_EmailAttachment extends Syncroton_Model_Attachment { protected $_xmlBaseElement = 'Attachment'; - - protected $_properties = array( - 'AirSyncBase' => array( - 'contentId' => array('type' => 'string'), - 'contentLocation' => array('type' => 'string'), - 'displayName' => array('type' => 'string'), - 'estimatedDataSize' => array('type' => 'string'), - 'fileReference' => array('type' => 'string'), - 'isInline' => array('type' => 'number'), - 'method' => array('type' => 'string'), - ), - 'Email2' => array( - 'umAttDuration' => array('type' => 'number', 'supportedSince' => '14.0'), - 'umAttOrder' => array('type' => 'number', 'supportedSince' => '14.0'), - ), - ); -} \ No newline at end of file +} diff --git a/lib/ext/Syncroton/Model/EmailRecurrence.php b/lib/ext/Syncroton/Model/EmailRecurrence.php index b90cf19..fadbabb 100644 --- a/lib/ext/Syncroton/Model/EmailRecurrence.php +++ b/lib/ext/Syncroton/Model/EmailRecurrence.php @@ -1,74 +1,74 @@ */ /** * class to handle Email::Recurrence * * @package Syncroton * @subpackage Model * @property int CalendarType * @property int DayOfMonth * @property int DayOfWeek * @property int FirstDayOfWeek * @property int Interval * @property int IsLeapMonth * @property int MonthOfYear * @property int Occurrences * @property int Type * @property DateTime Until * @property int WeekOfMonth */ - class Syncroton_Model_EmailRecurrence extends Syncroton_Model_AXMLEntry { protected $_xmlBaseElement = 'Recurrence'; /** * recur types */ const TYPE_DAILY = 0; // Recurs daily const TYPE_WEEKLY = 1; // Recurs weekly const TYPE_MONTHLY = 3; // Recurs monthly const TYPE_MONTHLY_DAYN = 2; // Recurs monthly on the nth day const TYPE_YEARLY = 5; // Recurs yearly on the nth day of the nth month each year const TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day of the week of the nth month /** * day of week constants */ const RECUR_DOW_SUNDAY = 1; const RECUR_DOW_MONDAY = 2; const RECUR_DOW_TUESDAY = 4; const RECUR_DOW_WEDNESDAY = 8; const RECUR_DOW_THURSDAY = 16; const RECUR_DOW_FRIDAY = 32; const RECUR_DOW_SATURDAY = 64; protected $_dateTimeFormat = "Ymd\THis\Z"; protected $_properties = array( 'Email' => array( 'dayOfMonth' => array('type' => 'number'), 'dayOfWeek' => array('type' => 'number'), 'interval' => array('type' => 'number'), // 1 or 2 'monthOfYear' => array('type' => 'number'), 'occurrences' => array('type' => 'number'), 'type' => array('type' => 'number'), 'until' => array('type' => 'datetime'), 'weekOfMonth' => array('type' => 'number'), ), 'Email2' => array( 'calendarType' => array('type' => 'number'), 'firstDayOfWeek' => array('type' => 'number'), 'isLeapMonth' => array('type' => 'number'), ) ); } diff --git a/lib/ext/Syncroton/Model/Event.php b/lib/ext/Syncroton/Model/Event.php index 2289f79..927928a 100644 --- a/lib/ext/Syncroton/Model/Event.php +++ b/lib/ext/Syncroton/Model/Event.php @@ -1,147 +1,149 @@ */ /** * Class to handle ActiveSync event * * @package Syncroton * @subpackage Model * * @property Syncroton_Model_EmailBody $body * @property bool $allDayEvent * @property DateTime $appointmentReplyTime + * @property Syncroton_Model_Attachments $attachments * @property Syncroton_Model_EventAttendee[] $attendees * @property int $busyStatus * @property array $categories * @property string $clientUid * @property bool $disallowNewTimeProposal * @property DateTime $dtStamp * @property DateTime $endTime * @property Syncroton_Model_EventException[] $exceptions * @property Syncroton_Model_Location $location * @property int $meetingStatus * @property string $onlineMeetingConfLink * @property string $onlineMeetingExternalLink * @property string $organizerEmail * @property string $organizerName * @property Syncroton_Model_EventRecurrence $recurrence * @property int $reminder * @property int $responseRequested * @property int $responseType * @property int $sensitivity * @property DateTime $startTime * @property string $subject * @property string $timezone * @property string $uID */ class Syncroton_Model_Event extends Syncroton_Model_AXMLEntry { /** * busy status constants */ const BUSY_STATUS_FREE = 0; const BUSY_STATUS_TENATTIVE = 1; const BUSY_STATUS_BUSY = 2; protected $_dateTimeFormat = "Ymd\THis\Z"; protected $_xmlBaseElement = 'ApplicationData'; protected $_properties = array( 'AirSyncBase' => array( - 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody'), - 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), + 'attachments' => array('type' => 'container', 'class' => 'Syncroton_Model_Attachments', 'supportedSince' => '16.0'), + 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody'), + 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), ), 'Calendar' => array( 'allDayEvent' => array('type' => 'number'), 'appointmentReplyTime' => array('type' => 'datetime'), 'attendees' => array('type' => 'container', 'childElement' => 'attendee', 'class' => 'Syncroton_Model_EventAttendee'), 'busyStatus' => array('type' => 'number'), 'categories' => array('type' => 'container', 'childElement' => 'category'), 'clientUid' => array('type' => 'string', 'supportedSince' => '16.0'), 'disallowNewTimeProposal' => array('type' => 'number'), 'dtStamp' => array('type' => 'datetime'), 'endTime' => array('type' => 'datetime'), 'exceptions' => array('type' => 'container', 'childElement' => 'exception', 'class' => 'Syncroton_Model_EventException'), 'location' => array('type' => 'string', 'supportedUntil' => '16.0'), 'meetingStatus' => array('type' => 'number'), 'onlineMeetingConfLink' => array('type' => 'string'), 'onlineMeetingExternalLink' => array('type' => 'string'), 'organizerEmail' => array('type' => 'string'), 'organizerName' => array('type' => 'string'), 'recurrence' => array('type' => 'container', 'class' => 'Syncroton_Model_EventRecurrence'), 'reminder' => array('type' => 'number'), 'responseRequested' => array('type' => 'number'), 'responseType' => array('type' => 'number'), 'sensitivity' => array('type' => 'number'), 'startTime' => array('type' => 'datetime'), 'subject' => array('type' => 'string'), 'timezone' => array('type' => 'timezone'), 'uID' => array('type' => 'string'), ) ); /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::appendXML() * @todo handle Attendees element */ public function appendXML(DOMElement $domParrent, Syncroton_Model_IDevice $device) { parent::appendXML($domParrent, $device); $exceptionElements = $domParrent->getElementsByTagName('Exception'); $parentFields = array('AllDayEvent'/*, 'Attendees'*/, 'Body', 'BusyStatus'/*, 'Categories'*/, 'DtStamp', 'EndTime', 'Location', 'MeetingStatus', 'Reminder', 'ResponseType', 'Sensitivity', 'StartTime', 'Subject'); if ($exceptionElements->length > 0) { $mainEventElement = $exceptionElements->item(0)->parentNode->parentNode; foreach ($mainEventElement->childNodes as $childNode) { if (in_array($childNode->localName, $parentFields)) { foreach ($exceptionElements as $exception) { $elementsToLeftOut = $exception->getElementsByTagName($childNode->localName); foreach ($elementsToLeftOut as $elementToLeftOut) { if ($elementToLeftOut->nodeValue == $childNode->nodeValue) { $exception->removeChild($elementToLeftOut); } } } } } } } /** * some elements of an exception can be left out, if they have the same value * like the main event * * this function copies these elements to the exception for backends which need * this elements in the exceptions too. Tine 2.0 needs this for example. */ public function copyFieldsFromParent() { if (isset($this->_elements['exceptions']) && is_array($this->_elements['exceptions'])) { foreach ($this->_elements['exceptions'] as $exception) { // no need to update deleted exceptions if ($exception->deleted == 1) { continue; } $parentFields = array('allDayEvent', 'attendees', 'body', 'busyStatus', 'categories', 'dtStamp', 'endTime', 'location', 'meetingStatus', 'reminder', 'responseType', 'sensitivity', 'startTime', 'subject'); foreach ($parentFields as $field) { if (!isset($exception->$field) && isset($this->_elements[$field])) { $exception->$field = $this->_elements[$field]; } } } } } } diff --git a/lib/ext/Syncroton/Model/EventAttendee.php b/lib/ext/Syncroton/Model/EventAttendee.php index 8803427..59ee59c 100644 --- a/lib/ext/Syncroton/Model/EventAttendee.php +++ b/lib/ext/Syncroton/Model/EventAttendee.php @@ -1,53 +1,51 @@ */ /** * class to handle ActiveSync event * * @package Syncroton * @subpackage Model - * @property string class - * @property string collectionId - * @property bool deletesAsMoves - * @property bool getChanges - * @property string syncKey - * @property int windowSize + * + * @property int $attendeStatus + * @property int $attendeeType + * @property string $email + * @property string $name */ - class Syncroton_Model_EventAttendee extends Syncroton_Model_AXMLEntry { + /** + * 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; + protected $_xmlBaseElement = 'Attendee'; - - /** - * 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; - + protected $_properties = array( 'Calendar' => array( - 'attendeeStatus' => array('type' => 'number'), - 'attendeeType' => array('type' => 'number'), - 'email' => array('type' => 'string'), - 'name' => array('type' => 'string'), + 'attendeeStatus' => array('type' => 'number'), + 'attendeeType' => array('type' => 'number'), + 'email' => array('type' => 'string'), + 'name' => array('type' => 'string'), ) ); -} \ No newline at end of file +} diff --git a/lib/ext/Syncroton/Model/EventRecurrence.php b/lib/ext/Syncroton/Model/EventRecurrence.php index ac53984..8e34364 100644 --- a/lib/ext/Syncroton/Model/EventRecurrence.php +++ b/lib/ext/Syncroton/Model/EventRecurrence.php @@ -1,72 +1,72 @@ */ /** * class to handle ActiveSync event * * @package Syncroton * @subpackage Model - * @property int CalendarType - * @property int DayOfMonth - * @property int DayOfWeek - * @property int FirstDayOfWeek - * @property int Interval - * @property int IsLeapMonth - * @property int MonthOfYear - * @property int Occurrences - * @property int Type - * @property DateTime Until - * @property int WeekOfMonth + * @property int $calendarType + * @property int $dayOfMonth + * @property int $dayOfWeek + * @property int $firstDayOfWeek + * @property int $interval + * @property int $isLeapMonth + * @property int $monthOfYear + * @property int $occurrences + * @property int $type + * @property DateTime $until + * @property int $weekOfMonth */ - class Syncroton_Model_EventRecurrence extends Syncroton_Model_AXMLEntry { + /** + * recur types + */ + const TYPE_DAILY = 0; // Recurs daily. + const TYPE_WEEKLY = 1; // Recurs weekly + const TYPE_MONTHLY = 2; // Recurs monthly + const TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day + const TYPE_YEARLY = 5; // Recurs yearly + const TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day + + /** + * day of week constants + */ + const RECUR_DOW_SUNDAY = 1; + const RECUR_DOW_MONDAY = 2; + const RECUR_DOW_TUESDAY = 4; + const RECUR_DOW_WEDNESDAY = 8; + const RECUR_DOW_THURSDAY = 16; + const RECUR_DOW_FRIDAY = 32; + const RECUR_DOW_SATURDAY = 64; + protected $_xmlBaseElement = 'Recurrence'; - - /** - * recur types - */ - const TYPE_DAILY = 0; // Recurs daily. - const TYPE_WEEKLY = 1; // Recurs weekly - const TYPE_MONTHLY = 2; // Recurs monthly - const TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day - const TYPE_YEARLY = 5; // Recurs yearly - const TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day - - /** - * day of week constants - */ - const RECUR_DOW_SUNDAY = 1; - const RECUR_DOW_MONDAY = 2; - const RECUR_DOW_TUESDAY = 4; - const RECUR_DOW_WEDNESDAY = 8; - const RECUR_DOW_THURSDAY = 16; - const RECUR_DOW_FRIDAY = 32; - const RECUR_DOW_SATURDAY = 64; - + protected $_dateTimeFormat = "Ymd\THis\Z"; - + protected $_properties = array( 'Calendar' => array( - 'calendarType' => array('type' => 'number'), - 'dayOfMonth' => array('type' => 'number'), - 'dayOfWeek' => array('type' => 'number'), - 'firstDayOfWeek' => array('type' => 'number'), - 'interval' => array('type' => 'number'), - 'isLeapMonth' => array('type' => 'number'), - 'monthOfYear' => array('type' => 'number'), - 'occurrences' => array('type' => 'number'), - 'type' => array('type' => 'number'), - 'until' => array('type' => 'datetime'), - 'weekOfMonth' => array('type' => 'number'), + 'calendarType' => array('type' => 'number'), + 'dayOfMonth' => array('type' => 'number'), + 'dayOfWeek' => array('type' => 'number'), + 'firstDayOfWeek' => array('type' => 'number'), + 'interval' => array('type' => 'number'), + 'isLeapMonth' => array('type' => 'number'), + 'monthOfYear' => array('type' => 'number'), + 'occurrences' => array('type' => 'number'), + 'type' => array('type' => 'number'), + 'until' => array('type' => 'datetime'), + 'weekOfMonth' => array('type' => 'number'), ) ); -} \ No newline at end of file +} diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php index b558b3a..d843813 100644 --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -1,1115 +1,1166 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * 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; const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP'; const KEY_RESPONSE_DTSTAMP = 'x-custom.X-ACTIVESYNC-RESPONSE-DTSTAMP'; /** * 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 boolean $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['_mailbox']); $result = array(); // Timezone // 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 if ($event['start'] instanceof DateTime) { $timezone = $event['start']->getTimezone(); if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') { $tzc = kolab_sync_timezone_converter::getInstance(); if ($tz_name = $tzc->encodeTimezone($tz_name)) { $result['timezone'] = $tz_name; } } } // 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 ($event['allday']) { // need this for self::date_from_kolab() $date->_dateonly = false; 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': $value = intval($this->sensitivityMap[$value]); break; case 'free_busy': $value = $this->busyStatusMap[$value]; break; case 'description': $value = $this->body_from_kolab($value, $collection); break; case 'location': $value = new Syncroton_Model_Location($value); break; } // Ignore empty values (but not integer 0) if ((empty($value) || is_array($value)) && $value !== 0) { continue; } $result[$key] = $value; } // Event reminder time if ($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 ($name = $attendee['name']) { $result['organizerName'] = $name; } if ($email = $attendee['email']) { $result['organizerEmail'] = $email; } unset($event['attendees'][$idx]); break; } } } // Attendees if (!empty($event['attendees'])) { $user_emails = $this->user_emails(); $user_rsvp = false; foreach ($event['attendees'] as $idx => $attendee) { $att = array(); if ($email = $attendee['email']) { $att['email'] = $email; } else { // In Activesync email is required continue; } $att['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) { $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; } $result['attendees'][] = new Syncroton_Model_EventAttendee($att); } } // Event meeting status $this->meeting_status_from_kolab($collection, $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; + // Attachments + if ($this->asversion >= 16) { + $attachments = array(); + if (!empty($event['_attachments'])) { + foreach ($event['_attachments'] as $attachment) { + $attachments[] = array( + 'displayName' => rcube_charset::clean($attachment['name']), + 'contentType' => rcube_charset::clean($attachment['mimetype']), + 'fileReference' => sprintf('%s||%s||%s', $collection->collectionId, $serverId, $attachment['id']), + 'method' => Syncroton_Model_Attachment::METHOD_NORMAL, + 'estimatedDataSize' => $attachment['size'], + // todo: clientId + ); + } + } + + $result['attachments'] = new Syncroton_Model_Attachments($attachments); + } + return $as_array ? $result : new Syncroton_Model_Event($result); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_IEntry $data Contact to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * @param DateTimeZone $timezone Timezone of the event * * @return array */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null) { $event = !empty($entry) ? $entry : array(); $foldername = isset($event['_mailbox']) ? $event['_mailbox'] : $this->getFolderName($folderid); $config = $this->getFolderConfig($foldername); $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; $v16_update = $this->asversion >= 16 && !empty($event); // check data validity $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 = $old_timezone ?: kolab_format::$timezone; try { $timezone = $tzc->getTimezone($data->timezone, $expected->getName()); $timezone = new DateTimeZone($timezone); } catch (Exception $e) { $timezone = null; } } if (empty($timezone)) { $timezone = $old_timezone ?: new DateTimeZone('UTC'); } $event['allday'] = $v16_update && !isset($data->allDayEvent) ? $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; } // In ActiveSync >= v16 all properties are ghosted if ($v16_update && !isset($data->$key)) { continue; } $value = $data->$key; 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 = $map[$value]; break; case 'free_busy': $map = array_flip($this->busyStatusMap); $value = $map[$value]; 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; case 'location': $value = (string) $value; 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 (!$event['allday'] && $event['end'] && $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 ($config['ALARMS'] && (!$v16_update || isset($data->reminder))) { $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; } } if (!$v16_update || isset($data->categories)) { $event['categories'] = $categories; } // Organizer if (!$is_exception && ($organizer_email = $data->organizerEmail)) { $attendees[] = array( 'role' => 'ORGANIZER', 'name' => $data->organizerName, 'email' => $organizer_email, ); } // Attendees // Outlook 2013 sends a dummy update just after MeetingResponse has been processed, // this update resets attendee status set in the MeetingResponse request. // We ignore changes to attendees data on such updates if ($is_outlook && $this->isDummyOutlookUpdate($data, $entry, $event)) { $attendees = $entry['attendees']; } else if (isset($data->attendees)) { $statusMap = array_flip($this->attendeeStatusMap); foreach ($data->attendees as $attendee) { if ($attendee->email && $attendee->email == $organizer_email) { 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->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; } } // 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'], ); } // Note: In ActiveSync >= v16 all properties are ghosted if (!$v16_update || isset($data->attendees) || isset($data->organizerEmail)) { $event['attendees'] = $attendees; } // recurrence (and exceptions) if (!$is_exception) { $event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone); } // 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 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 ($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 && $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; } + // Attachments Add/Delete + if (isset($data->attachments) && $v16_update) { + // @TODO + } + return $event; } + /** + * @return Syncroton_Model_FileReference + */ + public function getFileReference($fileReference) + { + list($collectionId, $serverId, $partId) = explode('||', $fileReference); + + $event = $this->getObject($collectionId, $serverId, $folder); + + if (!$event) { + throw new Syncroton_Exception_NotFound('Event not found'); + } + + foreach ((array) $event['_attachments'] as $attachment) { + if (strval($attachments['id']) === strval($partId)) { + $body = $folder->get_attachment($event['_uid'], $partId, $event['_mailbox'], false, null, true); + + return new Syncroton_Model_FileReference(array( + 'contentType' => rcube_charset::clean($attachment['mimetype']), + 'data' => $body, + )); + } + } + + throw new Syncroton_Exception_NotFound('File reference not found'); + } + /** * 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', ); if ($status = $status_map[$request->userResponse]) { // 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 Outlook response timestamp for further use if (stripos($this->device->devicetype, 'outlook') !== false) { $dtstamp = new DateTime('now', new DateTimeZone('UTC')); $dtstamp = $dtstamp->format(DateTime::ATOM); } // Update/Save the event if (empty($existing)) { if ($dtstamp) { $this->setKolabDataItem($event, self::KEY_RESPONSE_DTSTAMP, $dtstamp); } $folder = $this->save_event($event, $status); // Create SyncState for the new event, so it is not synced twice if ($folder) { $folderId = $this->getFolderId($folder); 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'], $folder), 'creation_time' => $syncState->lastsync, 'creation_synckey' => $syncState->counter, ))); } catch (Exception $e) { // ignore } } } else { if ($dtstamp) { $this->setKolabDataItem($existing, self::KEY_RESPONSE_DTSTAMP, $dtstamp); } $folder = $this->update_event($event, $existing, $status, $request->instanceId); } if (!$folder) { 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 empty($status) ? null : $this->serverId($event['uid'], $folder); } /** * 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, $folder)) { 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? foreach ($this->listFolders() as $folder) { $storage_folder = $this->getFolderObject($folder['imap_name']); if ($storage_folder->get_namespace() == 'personal' && ($result = $storage_folder->get_object($uid)) ) { 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']; } // Updating an existing event is most-likely a response // to an iTip request with bumped SEQUENCE $old['sequence'] += 1; // 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) { // Find default folder to which we'll save the event if (!isset($event['_mailbox'])) { $folders = $this->listFolders(); $storage = rcube::get_instance()->get_storage(); // find the default foreach ($folders as $folder) { if ($folder['type'] == 8 && $storage->folder_namespace($folder['imap_name']) == 'personal') { $event['_mailbox'] = $folder['imap_name']; break; } } // if there's no folder marked as default, use any if (!isset($event['_mailbox']) && !empty($folders)) { foreach ($folders as $folder) { if ($storage->folder_namespace($folder['imap_name']) == 'personal') { $event['_mailbox'] = $folder['imap_name']; break; } } } // 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? if (isset($event['_mailbox'])) { $folder = $this->getFolderObject($event['_mailbox']); if ($folder && $folder->valid && $folder->save($event)) { return $folder; } } return false; } /** * Update the attendee status of the user */ protected function update_attendee_status(&$event, $status) { $organizer = null; $emails = $this->user_emails(); foreach ((array) $event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] && in_array_nocase($attendee['email'], $emails)) { $event['attendees'][$i]['status'] = $status; $event['attendees'][$i]['rsvp'] = false; $event_attendee = $attendee; } } if (!$event_attendee) { $this->logger->warn('MeetingResponse on an event where the user is not an attendee. UID: ' . $event['uid']); throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(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($collection, $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 ($value && $value instanceof DateTime) { if ($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 ($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 (array) $event['valarms']; } $valarms = array(); $unsupported = array(); if (!empty($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (!$current && in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) { $current = $alarm; } else { $unsupported[] = $alarm; } } } $valarms[] = array( 'action' => $current['action'] ?: 'DISPLAY', '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 ($event[$key] != $old[$key]) { // 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; } /** * Check if the event update request is a fake (for Outlook) */ protected function isDummyOutlookUpdate($data, $entry, &$result) { $is_dummy = false; // Outlook 2013 sends a dummy update just after MeetingResponse has been processed, // this update resets attendee status set in the MeetingResponse request. // We ignore attendees data in such updates, they should not happen according to // https://msdn.microsoft.com/en-us/library/office/hh428685(v=exchg.140).aspx // but they will contain some data as alarms and free/busy status so we don't // ignore them completely if (!empty($entry) && !empty($data->attendees) && stripos($this->device->devicetype, 'outlook') !== false) { // Some of these requests use just dummy Timezone $dummy_tz = str_repeat('A', 230) . '=='; if ($data->timezone == $dummy_tz) { $is_dummy = true; } // But some of them do not, so we have check if that is a first // update immediately (up to 5 seconds) after MeetingResponse request if (!$is_dummy && ($dtstamp = $this->getKolabDataItem($entry, self::KEY_RESPONSE_DTSTAMP))) { $dtstamp = new DateTime($dtstamp); $now = new DateTime('now', new DateTimeZone('UTC')); $is_dummy = $now->getTimestamp() - $dtstamp->getTimestamp() <= 5; } $this->unsetKolabDataItem($result, self::KEY_RESPONSE_DTSTAMP); } return $is_dummy; } } diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php index 8a847a0..5030512 100644 --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -1,1723 +1,1724 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Email data class for Syncroton */ class kolab_sync_data_email extends kolab_sync_data implements Syncroton_Data_IDataSearch { const MAX_SEARCH_RESULT = 200; /** * Mapping from ActiveSync Email namespace fields */ protected $mapping = array( 'cc' => 'cc', //'contentClass' => 'contentclass', 'dateReceived' => 'internaldate', //'displayTo' => 'displayto', //? //'flag' => 'flag', 'from' => 'from', //'importance' => 'importance', 'internetCPID' => 'charset', //'messageClass' => 'messageclass', 'replyTo' => 'replyto', //'read' => 'read', 'subject' => 'subject', //'threadTopic' => 'threadtopic', 'to' => 'to', ); /** * Special folder type/name map * * @var array */ protected $folder_types = array( 2 => 'Inbox', 3 => 'Drafts', 4 => 'Deleted Items', 5 => 'Sent Items', 6 => 'Outbox', ); /** * Kolab object type * * @var string */ protected $modelName = 'mail'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_INBOX; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'INBOX'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { parent::__construct($device, $syncTimeStamp); $this->storage = rcube::get_instance()->get_storage(); // Outlook 2013 support multi-folder $this->ext_devices[] = 'windowsoutlook15'; if ($this->asversion >= 14) { $this->tag_categories = true; } } /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * * @return Syncroton_Model_Email Email object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $message = $this->getObject($serverId); // error (message doesn't exist?) if (empty($message)) { throw new Syncroton_Exception_NotFound("Message $serverId not found"); } $headers = $message->headers; // rcube_message_header $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) $prefs[$type]['preview']; $airSyncBaseType = $type; break; } } } $body_params = array('type' => $airSyncBaseType); // Message body // In Sync examples there's one in which bodyPreferences is not defined // in such case Truncated=1 and there's no body sent to the client // only it's estimated size if (empty($prefs)) { $messageBody = ''; $real_length = $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); 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 if ($headers->ctype == 'multipart/signed' && count($message->attachments) == 1 && $message->attachments[0]->mimetype == 'application/pkcs7-signature' ) { $result['messageClass'] = 'IPM.Note.SMIME.MultipartSigned'; } else if ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') { $result['messageClass'] = 'IPM.Note.SMIME'; } else { $result['messageClass'] = 'IPM.Note'; } $result['contentClass'] = 'urn:content-classes:message'; // Categories (Tags) if ($this->tag_categories) { // convert kolab tags into categories $result['categories'] = $this->getKolabTags($message); } // attachments $attachments = array_merge($message->attachments, $message->inline_parts); if (!empty($attachments)) { $result['attachments'] = array(); foreach ($attachments as $attachment) { - $att = array(); - $filename = rcube_charset::clean($attachment->filename); if (empty($filename) && $attachment->mimetype == 'text/html') { $filename = 'HTML Part'; } - $att['displayName'] = $filename; - $att['fileReference'] = $serverId . '::' . $attachment->mime_id; - $att['method'] = 1; - $att['estimatedDataSize'] = $attachment->size; + $att = array( + 'displayName' => $filename, + 'fileReference' => $serverId . '::' . $attachment->mime_id, + 'method' => Syncroton_Model_Attachment::METHOD_NORMAL, + '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); + $result['attachments'][] = new Syncroton_Model_Attachment($att); } } return new Syncroton_Model_Email($result); } /** * Returns properties of a message for Search response * * @param string $longId Message identifier * @param array $options Search options * * @return Syncroton_Model_Email Email object */ public function getSearchEntry($longId, $options) { $collection = new Syncroton_Model_SyncCollection(array( 'options' => $options, )); return $this->getEntry($collection, $longId); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_IEntry $data Contact to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * * @return array */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null) { // does nothing => you can't add emails via ActiveSync } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(); switch ($filter_type) { case Syncroton_Command_Sync::FILTER_1_DAY_BACK: $mod = '-1 day'; break; case Syncroton_Command_Sync::FILTER_3_DAYS_BACK: $mod = '-3 days'; break; case Syncroton_Command_Sync::FILTER_1_WEEK_BACK: $mod = '-1 week'; break; case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); // RFC3501: IMAP SEARCH $filter[] = 'SINCE ' . $dt->format('d-M-Y'); } return $filter; } /** * Return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = $this->listFolders(); if (!is_array($list)) { throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR); } // device doesn't support multiple folders if (!$this->isMultiFolder()) { // We'll return max. one folder of supported type $result = array(); $types = $this->folder_types; foreach ($list as $idx => $folder) { $type = $folder['type'] == 12 ? 2 : $folder['type']; // unknown to Inbox if ($folder_id = $types[$type]) { $result[$folder_id] = array( 'displayName' => $folder_id, 'serverId' => $folder_id, 'parentId' => 0, 'type' => $type, ); } } $list = $result; } foreach ($list as $idx => $folder) { $list[$idx] = new Syncroton_Model_Folder($folder); } return $list; } /** * Return list of folders for specified folder ID * * @return array Folder identifiers list */ protected function extractFolders($folder_id) { $list = $this->listFolders(); $result = array(); if (!is_array($list)) { throw new Syncroton_Exception_NotFound('Folder not found'); } // device supports multiple folders? if ($this->isMultiFolder()) { if ($list[$folder_id]) { $result[] = $folder_id; } } else if ($type = array_search($folder_id, $this->folder_types)) { foreach ($list as $id => $folder) { if ($folder['type'] == $type || ($folder_id == 'Inbox' && $folder['type'] == 12)) { $result[] = $id; } } } if (empty($result)) { throw new Syncroton_Exception_NotFound('Folder not found'); } return $result; } /** * Moves object into another location (folder) * * @param string $srcFolderId Source folder identifier * @param string $serverId Object identifier * @param string $dstFolderId Destination folder identifier * * @throws Syncroton_Exception_Status * @return string New object identifier */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { $msg = $this->parseMessageId($serverId); $dest = $this->extractFolders($dstFolderId); $dest_id = array_shift($dest); $dest_name = $this->backend->folder_id2name($dest_id, $this->device->deviceid); if (empty($msg)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } if ($dest_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$this->storage->move_message($msg['uid'], $dest_name, $msg['foldername'])) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } // Use COPYUID feature (RFC2359) to get the new UID of the copied message $copyuid = $this->storage->conn->data['COPYUID']; if (is_array($copyuid) && ($uid = $copyuid[1])) { return $this->createMessageId($dest_id, $uid); } } /** * add entry from xml data * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry * * @return array */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { // Throw exception here for better handling of unsupported // entry creation, it can be object of class Email or SMS here throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } /** * Update existing message * * @param string $folderId Folder identifier * @param string $serverId Entry identifier * @param Syncroton_Model_IEntry $entry Entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) { $msg = $this->parseMessageId($serverId); $message = $this->getObject($serverId); if (empty($message)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $is_flagged = !empty($message->headers->flags['FLAGGED']); // Read status change if (isset($entry->read)) { // here we update only Read flag $flag = (((int)$entry->read != 1) ? 'UN' : '') . 'SEEN'; $this->storage->set_flag($msg['uid'], $flag, $msg['foldername']); } // Flag change if (isset($entry->flag) && (empty($entry->flag) || empty($entry->flag->flagType))) { if ($is_flagged) { $this->storage->set_flag($msg['uid'], 'UNFLAGGED', $msg['foldername']); } } else if (!$is_flagged && !empty($entry->flag)) { if ($entry->flag->flagType && preg_match('/follow\s*up/i', $entry->flag->flagType)) { $this->storage->set_flag($msg['uid'], 'FLAGGED', $msg['foldername']); } } // Categories (Tags) change if (isset($entry->categories)) { $this->setKolabTags($message, $entry->categories); } } /** * delete entry * * @param string $folderId * @param string $serverId * @param Syncroton_Model_SyncCollection $collection */ public function deleteEntry($folderId, $serverId, $collection) { $trash = kolab_sync::get_instance()->config->get('trash_mbox'); $msg = $this->parseMessageId($serverId); if (empty($msg)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } // move message to trash folder if ($collection->deletesAsMoves && strlen($trash) && $trash != $msg['foldername'] && $this->storage->folder_exists($trash) ) { $this->storage->move_message($msg['uid'], $trash, $msg['foldername']); } // set delete flag else { $this->storage->set_flag($msg['uid'], 'DELETED', $msg['foldername']); } } /** * Send an email * * @param mixed $message MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * * @throws Syncroton_Exception_Status */ public function sendEmail($message, $saveInSent) { if (!($message instanceof kolab_sync_message)) { $message = new kolab_sync_message($message); } $sent = $message->send($smtp_error); if (!$sent) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAIL_SUBMISSION_FAILED); } // Save sent message in Sent folder if ($saveInSent) { $sent_folder = kolab_sync::get_instance()->config->get('sent_mbox'); if (strlen($sent_folder) && $this->storage->folder_exists($sent_folder)) { return $this->storage->save_message($sent_folder, $message->source(), '', false, array('SEEN')); } } } /** * Forward an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function forwardEmail($itemId, $body, $saveInSent, $replaceMime) { /* @TODO: The SmartForward command can be applied to a meeting. When SmartForward is applied to a recurring meeting, the InstanceId element (section 2.2.3.83.2) specifies the ID of a particular occurrence in the recurring meeting. If SmartForward is applied to a recurring meeting and the InstanceId element is absent, the server SHOULD forward the entire recurring meeting. If the value of the InstanceId element is invalid, the server responds with Status element (section 2.2.3.162.15) value 104, as specified in section 2.2.4. When the SmartForward command is used for an appointment, the original message is included by the server as an attachment to the outgoing message. When the SmartForward command is used for a normal message or a meeting, the behavior of the SmartForward command is the same as that of the SmartReply command (section 2.2.2.18). */ $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } // Parse message $sync_msg = new kolab_sync_message($body); // forward original message as attachment if (!$replaceMime) { $this->storage->set_folder($msg['foldername']); $attachment = $this->storage->get_raw_body($msg['uid']); if (empty($attachment)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg->add_attachment($attachment, array( 'encoding' => '8bit', 'content_type' => 'message/rfc822', 'disposition' => 'inline', //'name' => 'message.eml', )); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set FORWARDED flag on the replied message if (empty($message->headers->flags['FORWARDED'])) { $this->storage->set_flag($msg['uid'], 'FORWARDED', $msg['foldername']); } } /** * Reply to an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function replyEmail($itemId, $body, $saveInSent, $replaceMime) { $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg = new kolab_sync_message($body); $headers = $sync_msg->headers(); // Add References header if (empty($headers['References'])) { $sync_msg->set_header('References', trim($message->headers->references . ' ' . $message->headers->messageID)); } // Get original message body if (!$replaceMime) { // @TODO: here we're assuming that reply message is in text/plain format // So, original message will be converted to plain text if needed $message_body = $this->getMessageBody($message, false); // Quote original message body $message_body = self::wrap_and_quote(trim($message_body), 72); // Join bodies $sync_msg->append("\n" . ltrim($message_body)); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set ANSWERED flag on the replied message if (empty($message->headers->flags['ANSWERED'])) { $this->storage->set_flag($msg['uid'], 'ANSWERED', $msg['foldername']); } } /** * Search for existing entries * * @param string $folderid * @param array $filter * @param int $result_type Type of the result (see RESULT_* constants) * * @return array|int Search result as count or array of uids/objects */ protected function searchEntries($folderid, $filter = array(), $result_type = self::RESULT_UID) { $folders = $this->extractFolders($folderid); $filter_str = 'ALL UNDELETED'; // convert filter into one IMAP search string foreach ($filter as $idx => $filter_item) { if (is_array($filter_item)) { // This is a request for changes since last time // we'll use HIGHESTMODSEQ value from the last Sync if ($filter_item[0] == 'changed' && $filter_item[1] == '>') { $modseq_lasttime = $filter_item[2]; $modseq_data = array(); $modseq = (array) $this->backend->modseq_get($this->device->id, $folderid, $modseq_lasttime); } } else { $filter_str .= ' ' . $filter_item; } } // get members of modified relations $changed_msgs = $this->getChangesByRelations($folderid, $filter); $result = $result_type == self::RESULT_COUNT ? 0 : array(); $found = 0; $ts = time(); foreach ($folders as $folder_id) { $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); if ($foldername === null) { continue; } $found++; $this->storage->set_folder($foldername); // Synchronize folder (if it wasn't synced in this request already) if ($this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout() ) { $this->storage->folder_sync($foldername); } // We're in "get changes" mode if (isset($modseq_data)) { $folder_data = $this->storage->folder_data($foldername); $modified = false; // If previous HIGHESTMODSEQ doesn't exist we can't get changes // We can only get folder's HIGHESTMODSEQ value and store it for the next try // Skip search if HIGHESTMODSEQ didn't change if ($folder_data['HIGHESTMODSEQ']) { $modseq_data[$foldername] = $folder_data['HIGHESTMODSEQ']; if ($modseq_data[$foldername] != $modseq[$foldername]) { $modseq_update = true; if ($modseq && $modseq[$foldername]) { $modified = true; $filter_str .= " MODSEQ " . ($modseq[$foldername] + 1); } } } } else { $modified = true; } // We could use messages cache by replacing search() with index() // in some cases. This however is possible only if user has skip_deleted=true, // in his Roundcube preferences, otherwise we'd make often cache re-initialization, // because Roundcube message cache can work only with one skip_deleted // setting at a time. We'd also need to make sure folder_sync() was called // before (see above). // // if ($filter_str == 'ALL UNDELETED') // $search = $this->storage->index($foldername, null, null, true, true); // else if ($modified) { $search = $this->storage->search_once($foldername, $filter_str); if (!($search instanceof rcube_result_index) || $search->is_error()) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } switch ($result_type) { case self::RESULT_COUNT: $result += (int) $search->count(); break; case self::RESULT_UID: if ($uids = $search->get()) { foreach ($uids as $idx => $uid) { $uids[$idx] = $this->createMessageId($folder_id, $uid); } $result = array_merge($result, $uids); } break; } } // handle relation changes if (!empty($changed_msgs)) { $uids = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); switch ($result_type) { case self::RESULT_COUNT: $result += (int) count($uids); break; case self::RESULT_UID: foreach ($uids as $idx => $uid) { $uids[$idx] = $this->createMessageId($folder_id, $uid); } $result = array_unique(array_merge($result, $uids)); break; } } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $this->lastsync_folder = $folderid; $this->lastsync_time = $ts; if (!empty($modseq_update)) { $this->backend->modseq_set($this->device->id, $folderid, $this->syncTimeStamp, $modseq_data); // if previous modseq information does not exist save current set as it, // we would at least be able to detect changes since now if (empty($result) && empty($modseq)) { $this->backend->modseq_set($this->device->id, $folderid, $modseq_lasttime, $modseq_data); } } return $result; } /** * Find members (messages) in specified folder */ protected function findRelationMembersInFolder($foldername, $members, $filter) { foreach ($members as $member) { // IMAP URI members if ($url = kolab_storage_config::parse_member_url($member)) { $result[$url['folder']][$url['uid']] = $url['params']; } } // convert filter into one IMAP search string $filter_str = 'ALL UNDELETED'; foreach ($filter as $filter_item) { if (is_string($filter_item)) { $filter_str .= ' ' . $filter_item; } } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $found = array(); // first find messages by UID if (!empty($result[$foldername])) { $index = $storage->search_once($foldername, 'UID ' . rcube_imap_generic::compressMessageSet(array_keys($result[$foldername]))); $found = $index->get(); // remove found messages from the $result if (!empty($found)) { $result[$foldername] = array_diff_key($result[$foldername], array_flip($found)); if (empty($result[$foldername])) { unset($result[$foldername]); } // now apply the current filter to the found messages $index = $storage->search_once($foldername, $filter_str . ' UID ' . rcube_imap_generic::compressMessageSet($found)); $found = $index->get(); } } // search by message parameters if (!empty($result)) { // @TODO: do this search in chunks (for e.g. 25 messages)? $search = ''; $search_count = 0; foreach ($result as $data) { foreach ($data as $p) { $search_params = array(); $search_count++; foreach ($p as $key => $val) { $key = strtoupper($key); // don't search by subject, we don't want false-positives if ($key != 'SUBJECT') { $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); } } $search .= ' (' . implode(' ', $search_params) . ')'; } } $search_str .= ' ' . str_repeat(' OR', $search_count-1) . $search; // search messages in current folder $search = $storage->search_once($foldername, $search_str); $uids = $search->get(); if (!empty($uids)) { // add UIDs into the result $found = array_unique(array_merge($found, $uids)); } } return $found; } /** * ActiveSync Search handler * * @param Syncroton_Model_StoreRequest $store Search query * * @return Syncroton_Model_StoreResponse Complete Search response */ public function search(Syncroton_Model_StoreRequest $store) { list($folders, $search_str) = $this->parse_search_query($store); if (empty($search_str)) { throw new Exception('Empty/invalid search request'); } if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = array(); // @TODO: caching with Options->RebuildResults support foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null) { continue; } // $this->storage->set_folder($foldername); // $this->storage->folder_sync($foldername); $search = $this->storage->search_once($foldername, $search_str); if (!($search instanceof rcube_result_index)) { continue; } $uids = $search->get(); foreach ($uids as $idx => $uid) { $uids[$idx] = new Syncroton_Model_StoreResponseResult(array( 'longId' => $this->createMessageId($folderid, $uid), 'collectionId' => $folderid, 'class' => 'Email', )); } $result = array_merge($result, $uids); // We don't want to search all folders if we've got already a lot messages if (count($result) >= self::MAX_SEARCH_RESULT) { break; } } $result = array_values($result); $response = new Syncroton_Model_StoreResponse(); // Calculate requested range $start = (int) $store->options['range'][0]; $limit = (int) $store->options['range'][1] + 1; $total = count($result); $response->total = $total; // Get requested chunk of data set if ($total) { if ($start > $total) { $start = $total; } if ($limit > $total) { $limit = max($start+1, $total); } if ($start > 0 || $limit < $total) { $result = array_slice($result, $start, $limit-$start); } $response->range = array($start, $start + count($result) - 1); } // Build result array, convert to ActiveSync format foreach ($result as $idx => $rec) { $rec->properties = $this->getSearchEntry($rec->longId, $store->options); $response->result[] = $rec; unset($result[$idx]); } return $response; } /** * Converts ActiveSync search parameters into IMAP search string */ protected function parse_search_query($store) { $options = $store->options; $query = $store->query; $search_str = ''; $folders = array(); if (empty($query) || !is_array($query)) { return array(); } if (isset($query['and']['freeText']) && strlen($query['and']['freeText'])) { $search = $query['and']['freeText']; } if (!empty($query['and']['collections'])) { foreach ($query['and']['collections'] as $collection) { $folders = array_merge($folders, $this->extractFolders($collection)); } } if (!empty($query['and']['greaterThan']) && !empty($query['and']['greaterThan']['dateReceived']) && !empty($query['and']['greaterThan']['value']) ) { $search_str .= ' SINCE ' . $query['and']['greaterThan']['value']->format('d-M-Y'); } if (!empty($query['and']['lessThan']) && !empty($query['and']['lessThan']['dateReceived']) && !empty($query['and']['lessThan']['value']) ) { $search_str .= ' BEFORE ' . $query['and']['lessThan']['value']->format('d-M-Y'); } if ($search !== null) { // @FIXME: should we use TEXT/BODY search? // ActiveSync protocol specification says "indexed fields" $search_keys = array('SUBJECT', 'TO', 'FROM', 'CC'); $search_str .= str_repeat(' OR', count($search_keys)-1); foreach ($search_keys as $key) { $search_str .= sprintf(" %s {%d}\r\n%s", $key, strlen($search), $search); } } if (empty($search_str)) { return array(); } $search_str = 'ALL UNDELETED ' . trim($search_str); // @TODO: DeepTraversal if (empty($folders)) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } return array($folders, $search_str); } /** * Fetches the entry from the backend */ protected function getObject($entryid, $dummy = null, &$folder = null) { $message = $this->parseMessageId($entryid); if (empty($message)) { // @TODO: exception? return null; } // get message $message = new rcube_message($message['uid'], $message['foldername']); return $message && !empty($message->headers) ? $message : null; } /** * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference) { list($folderid, $uid, $part_id) = explode('::', $fileReference); $message = $this->getObject($fileReference); if (!$message) { throw new Syncroton_Exception_NotFound('Message not found'); } $part = $message->mime_parts[$part_id]; $body = $message->get_part_body($part_id); return new Syncroton_Model_FileReference(array( 'contentType' => $part->mimetype, 'data' => $body, )); } /** * Parses entry ID to get folder name and UID of the message */ protected function parseMessageId($entryid) { // replyEmail/forwardEmail if (is_array($entryid)) { $entryid = $entryid['itemId']; } // Note: the id might be in a form of ::[::] list($folderid, $uid) = explode('::', $entryid); if (empty($uid)) { return; } $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null || $foldername === false) { return; } return array( 'uid' => $uid, 'folderid' => $folderid, 'foldername' => $foldername, ); } /** * Creates entry ID of the message */ public function createMessageId($folderid, $uid) { return $folderid . '::' . $uid; } /** * Returns body of the message in specified format */ protected function getMessageBody($message, $html = false) { if (!is_array($message->parts) && empty($message->body)) { return ''; } if (!empty($message->parts)) { foreach ($message->parts as $part) { // skip no-content and attachment parts (#1488557) if ($part->type != 'content' || !$part->size || $message->is_attachment($part)) { continue; } return $this->getMessagePartBody($message, $part, $html); } } return $this->getMessagePartBody($message, $message, $html); } /** * Returns body of the message part in specified format */ protected function getMessagePartBody($message, $part, $html = false) { // Check if we have enough memory to handle the message in it $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 ''; } if ($html) { if ($part->ctype_secondary == 'html') { // charset was converted to UTF-8 in rcube_storage::get_message_part(), // change/add charset specification in HTML accordingly $meta = ''; // remove old meta tag and add the new one, making sure // that it is placed in the head $body = preg_replace('/]+charset=[a-z0-9-_]+[^>]*>/Ui', '', $body); $body = preg_replace('/(]*>)/Ui', '\\1'.$meta, $body, -1, $rcount); if (!$rcount) { $body = '' . $meta . '' . $body; } } else if ($part->ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); } else { // Roundcube >= 1.2 if (class_exists('rcube_text2html')) { $flowed = $part->ctype_parameters['format'] == 'flowed'; $delsp = $part->ctype_parameters['delsp'] == 'yes'; $options = array('flowed' => $flowed, 'wrap' => false, 'delsp' => $delsp); $text2html = new rcube_text2html($body, false, $options); $body = '' . $text2html->get_html() . ''; } else { $body = '
' . $body . '
'; } } } else { if ($part->ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); $part->ctype_secondary = 'html'; } if ($part->ctype_secondary == 'html') { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } else { if ($part->ctype_secondary == 'plain' && $part->ctype_parameters['format'] == 'flowed') { $body = rcube_mime::unfold_flowed($body); } } } return $body; } /** * Converts and truncates message body for use in * * @return string Truncated plain text message */ protected function getPreview($body, $type, $size) { if ($type == Syncroton_Command_Sync::BODY_TYPE_HTML) { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } // size limit defined in ActiveSync protocol if ($size > 255) { $size = 255; } return mb_strcut(trim($body), 0, $size); } /** * Returns list of tag names assigned to an email message */ protected function getKolabTags($message, $dummy = null) { // support only messages with message-id if (!($msg_id = $message->headers->get('message-id', false))) { return null; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $message->folder; $uid = $message->uid; // get tag objects raleted to specified message-id $tags = $config->get_tags($msg_id); foreach ($tags as $idx => $tag) { // resolve members if it wasn't done recently $force = empty($this->tag_rts[$tag['uid']]) || $this->tag_rts[$tag['uid']] <= time() - $delta; $members = $config->resolve_members($tag, $force); if (empty($members[$folder]) || !in_array($uid, $members[$folder])) { unset($tags[$idx]); } if ($force) { $this->tag_rts[$tag['uid']] = time(); } } $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); // make sure current folder is set correctly again $this->storage->set_folder($folder); return !empty($tags) ? $tags : null; } /** * Set tags to an email message */ protected function setKolabTags($message, $tags) { $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $message->folder; $uri = kolab_storage_config::get_message_uri($message->headers, $folder); // for all tag objects... foreach ($config->get_tags() as $relation) { // resolve members if it wasn't done recently $uid = $relation['uid']; $force = empty($this->tag_rts[$uid]) || $this->tag_rts[$uid] <= time() - $delta; if ($force) { $config->resolve_members($relation, $force); $this->tag_rts[$tag['uid']] = time(); } $selected = !empty($tags) && in_array($relation['name'], $tags); $found = !empty($relation['members']) && in_array($uri, $relation['members']); $update = false; // remove member from the relation if ($found && !$selected) { $relation['members'] = array_diff($relation['members'], (array) $uri); $update = true; } // add member to the relation else if (!$found && $selected) { $relation['members'][] = $uri; $update = true; } if ($update) { $config->save($relation, 'relation'); } $tags = array_diff($tags, (array) $relation['name']); } // create new relations if (!empty($tags)) { foreach ($tags as $tag) { $relation = array( 'name' => $tag, 'members' => (array) $uri, 'category' => 'tag', ); $config->save($relation, 'relation'); } } // make sure current folder is set correctly again $this->storage->set_folder($folder); } public static function charset_to_cp($charset) { // @TODO: ????? // The body is converted to utf-8 in get_part_body(), what about headers? return 65001; // UTF-8 $aliases = array( 'asmo708' => 708, 'shiftjis' => 932, 'gb2312' => 936, 'ksc56011987' => 949, 'big5' => 950, 'utf16' => 1200, 'utf16le' => 1200, 'unicodefffe' => 1201, 'utf16be' => 1201, 'johab' => 1361, 'macintosh' => 10000, 'macjapanese' => 10001, 'macchinesetrad' => 10002, 'mackorean' => 10003, 'macarabic' => 10004, 'machebrew' => 10005, 'macgreek' => 10006, 'maccyrillic' => 10007, 'macchinesesimp' => 10008, 'macromanian' => 10010, 'macukrainian' => 10017, 'macthai' => 10021, 'macce' => 10029, 'macicelandic' => 10079, 'macturkish' => 10081, 'maccroatian' => 10082, 'utf32' => 12000, 'utf32be' => 12001, 'chinesecns' => 20000, 'chineseeten' => 20002, 'ia5' => 20105, 'ia5german' => 20106, 'ia5swedish' => 20107, 'ia5norwegian' => 20108, 'usascii' => 20127, 'ibm273' => 20273, 'ibm277' => 20277, 'ibm278' => 20278, 'ibm280' => 20280, 'ibm284' => 20284, 'ibm285' => 20285, 'ibm290' => 20290, 'ibm297' => 20297, 'ibm420' => 20420, 'ibm423' => 20423, 'ibm424' => 20424, 'ebcdickoreanextended' => 20833, 'ibmthai' => 20838, 'koi8r' => 20866, 'ibm871' => 20871, 'ibm880' => 20880, 'ibm905' => 20905, 'ibm00924' => 20924, 'cp1025' => 21025, 'koi8u' => 21866, 'iso88591' => 28591, 'iso88592' => 28592, 'iso88593' => 28593, 'iso88594' => 28594, 'iso88595' => 28595, 'iso88596' => 28596, 'iso88597' => 28597, 'iso88598' => 28598, 'iso88599' => 28599, 'iso885913' => 28603, 'iso885915' => 28605, 'xeuropa' => 29001, 'iso88598i' => 38598, 'iso2022jp' => 50220, 'csiso2022jp' => 50221, 'iso2022jp' => 50222, 'iso2022kr' => 50225, 'eucjp' => 51932, 'euccn' => 51936, 'euckr' => 51949, 'hzgb2312' => 52936, 'gb18030' => 54936, 'isciide' => 57002, 'isciibe' => 57003, 'isciita' => 57004, 'isciite' => 57005, 'isciias' => 57006, 'isciior' => 57007, 'isciika' => 57008, 'isciima' => 57009, 'isciigu' => 57010, 'isciipa' => 57011, 'utf7' => 65000, 'utf8' => 65001, ); $charset = strtolower($charset); $charset = preg_replace(array('/^x-/', '/[^a-z0-9]/'), '', $charset); if (isset($aliases[$charset])) { return $aliases[$charset]; } if (preg_match('/^(ibm|dos|cp|windows|win)[0-9]+/', $charset, $m)) { return substr($charset, strlen($m[1]) + 1); } } /** * Wrap text to a given number of characters per line * but respect the mail quotation of replies messages (>). * Finally add another quotation level by prepending the lines * with > * * @param string $text Text to wrap * @param int $length The line width * * @return string The wrapped text */ protected static function wrap_and_quote($text, $length = 72) { // Function stolen from Roundcube ;) // Rebuild the message body with a maximum of $max chars, while keeping quoted message. $max = min(77, $length + 8); $lines = preg_split('/\r?\n/', trim($text)); $out = ''; foreach ($lines as $line) { // don't wrap already quoted lines if ($line[0] == '>') { $line = '>' . rtrim($line); } else if (mb_strlen($line) > $max) { $newline = ''; foreach (explode("\n", rcube_mime::wordwrap($line, $length - 2)) as $l) { if (strlen($l)) { $newline .= '> ' . $l . "\n"; } else { $newline .= ">\n"; } } $line = rtrim($newline); } else { $line = '> ' . $line; } // Append the line $out .= $line . "\n"; } return $out; } /** * Returns calendar event data from the iTip invitation attached to a mail message */ public function get_invitation_event($messageId) { // Get the mail message object if ($message = $this->getObject($messageId)) { // Parse the message and find iTip attachments $libcal = libcalendaring::get_instance(); $libcal->mail_message_load(array('object' => $message)); $ical_objects = $libcal->get_mail_ical_objects(); // We support only one event in the iTip foreach ($ical_objects as $mime_id => $event) { if ($event['_type'] == 'event') { return $event; } } } } /** * 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 (!rcube_utils::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 (rcube_utils::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); } } }