Page MenuHomePhorge

No OneTemporary

Size
171 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/ext/Syncroton/Command/Sync.php b/lib/ext/Syncroton/Command/Sync.php
index 4b5ebc5..426b739 100644
--- a/lib/ext/Syncroton/Command/Sync.php
+++ b/lib/ext/Syncroton/Command/Sync.php
@@ -1,763 +1,769 @@
<?php
/**
* Syncroton
*
* @package Syncroton
* @subpackage Command
* @license http://www.tine20.org/licenses/lgpl.html LGPL Version 3
* @copyright Copyright (c) 2009-2012 Metaways Infosystems GmbH (http://www.metaways.de)
* @author Lars Kneschke <l.kneschke@metaways.de>
*/
/**
* class to handle ActiveSync Sync command
*
* @package Syncroton
* @subpackage Command
*/
class Syncroton_Command_Sync extends Syncroton_Command_Wbxml
{
const STATUS_SUCCESS = 1;
const STATUS_PROTOCOL_VERSION_MISMATCH = 2;
const STATUS_INVALID_SYNC_KEY = 3;
const STATUS_PROTOCOL_ERROR = 4;
const STATUS_SERVER_ERROR = 5;
const STATUS_ERROR_IN_CLIENT_SERVER_CONVERSION = 6;
const STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT = 7;
const STATUS_OBJECT_NOT_FOUND = 8;
const STATUS_USER_ACCOUNT_MAYBE_OUT_OF_DISK_SPACE = 9;
const STATUS_ERROR_SETTING_NOTIFICATION_GUID = 10;
const STATUS_DEVICE_NOT_PROVISIONED_FOR_NOTIFICATIONS = 11;
const STATUS_FOLDER_HIERARCHY_HAS_CHANGED = 12;
const STATUS_RESEND_FULL_XML = 13;
const STATUS_WAIT_INTERVAL_OUT_OF_RANGE = 14;
const CONFLICT_OVERWRITE_SERVER = 0;
const CONFLICT_OVERWRITE_PIM = 1;
const MIMESUPPORT_DONT_SEND_MIME = 0;
const MIMESUPPORT_SMIME_ONLY = 1;
const MIMESUPPORT_SEND_MIME = 2;
const BODY_TYPE_PLAIN_TEXT = 1;
const BODY_TYPE_HTML = 2;
const BODY_TYPE_RTF = 3;
const BODY_TYPE_MIME = 4;
/**
* truncate types
*/
const TRUNCATE_ALL = 0;
const TRUNCATE_4096 = 1;
const TRUNCATE_5120 = 2;
const TRUNCATE_7168 = 3;
const TRUNCATE_10240 = 4;
const TRUNCATE_20480 = 5;
const TRUNCATE_51200 = 6;
const TRUNCATE_102400 = 7;
const TRUNCATE_NOTHING = 8;
/**
* filter types
*/
const FILTER_NOTHING = 0;
const FILTER_1_DAY_BACK = 1;
const FILTER_3_DAYS_BACK = 2;
const FILTER_1_WEEK_BACK = 3;
const FILTER_2_WEEKS_BACK = 4;
const FILTER_1_MONTH_BACK = 5;
const FILTER_3_MONTHS_BACK = 6;
const FILTER_6_MONTHS_BACK = 7;
const FILTER_INCOMPLETE = 8;
protected $_defaultNameSpace = 'uri:AirSync';
protected $_documentElement = 'Sync';
/**
* list of collections
*
* @var array
*/
protected $_collections = array();
protected $_modifications = array();
/**
* total count of items in all collections
*
* @var integer
*/
protected $_totalCount;
/**
* there are more entries than WindowSize available
* the MoreAvailable tag hot added to the xml output
*
* @var boolean
*/
protected $_moreAvailable = false;
/**
* @var Syncroton_Model_SyncState
*/
protected $_syncState;
/**
* process the XML file and add, change, delete or fetches data
*/
public function handle()
{
#if ($this->_statusProvisioning = $this->_checkProvisioningNeeded() !== false) {
# if (version_compare($this->_device->acsversion, '14.0', '<')) {
# throw new Syncroton_Exception_ProvisioningNeeded();
# } else {
# return;
# }
#}
// input xml
$xml = simplexml_import_dom($this->_requestBody);
foreach ($xml->Collections->Collection as $xmlCollection) {
$collectionData = new Syncroton_Model_SyncCollection($xmlCollection);
// got the folder synchronized to the device already
try {
$collectionData->folder = $this->_folderBackend->getFolder($this->_device, $collectionData->collectionId);
} catch (Syncroton_Exception_NotFound $senf) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " folder {$collectionData->collectionId} not found");
// trigger INVALID_SYNCKEY instead of OBJECT_NOTFOUND when synckey is higher than 0
// to avoid a syncloop for the iPhone
if ($collectionData->syncKey > 0) {
$collectionData->folder = new Syncroton_Model_Folder(array(
'device_id' => $this->_device,
'folderid' => $collectionData->collectionId
));
}
$this->_collections[] = $collectionData;
continue;
}
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " SyncKey is {$collectionData->syncKey} Class: {$collectionData->folder->class} CollectionId: {$collectionData->collectionId}");
// initial synckey
if($collectionData->syncKey === 0) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " initial client synckey 0 provided");
// reset sync state for this folder
$this->_syncStateBackend->resetState($this->_device, $collectionData->folder);
$this->_contentStateBackend->resetState($this->_device, $collectionData->folder);
$collectionData->syncState = new Syncroton_Model_SyncState(array(
'device_id' => $this->_device,
'counter' => 0,
'type' => $collectionData->folder,
'lastsync' => $this->_syncTimeStamp
));
$this->_collections[] = $collectionData;
continue;
}
// check for invalid sycnkey
if(($collectionData->syncState = $this->_syncStateBackend->validate($this->_device, $collectionData->folder, $collectionData->syncKey)) === false) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " invalid synckey {$collectionData->syncKey} provided");
// reset sync state for this folder
$this->_syncStateBackend->resetState($this->_device, $collectionData->folder);
$this->_contentStateBackend->resetState($this->_device, $collectionData->folder);
$this->_collections[] = $collectionData;
continue;
}
$dataController = Syncroton_Data_Factory::factory($collectionData->folder->class, $this->_device, $this->_syncTimeStamp);
switch($collectionData->folder->class) {
case Syncroton_Data_Factory::CLASS_CALENDAR:
$dataClass = 'Syncroton_Model_Event';
break;
case Syncroton_Data_Factory::CLASS_CONTACTS:
$dataClass = 'Syncroton_Model_Contact';
break;
case Syncroton_Data_Factory::CLASS_EMAIL:
$dataClass = 'Syncroton_Model_Email';
break;
case Syncroton_Data_Factory::CLASS_TASKS:
$dataClass = 'Syncroton_Model_Task';
break;
default:
throw new Syncroton_Exception_UnexpectedValue('invalid class provided');
break;
}
$clientModifications = array(
'added' => array(),
'changed' => array(),
'deleted' => array(),
'forceAdd' => array(),
'forceChange' => array(),
'toBeFetched' => array(),
);
// handle incoming data
if($collectionData->hasClientAdds()) {
$adds = $collectionData->getClientAdds();
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($adds) . " entries to be added to server");
foreach ($adds as $add) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->debug(__METHOD__ . '::' . __LINE__ . " add entry with clientId " . (string) $add->ClientId);
try {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " adding entry as new");
$serverId = $dataController->createEntry($collectionData->collectionId, new $dataClass($add->ApplicationData));
$clientModifications['added'][$serverId] = array(
'clientId' => (string)$add->ClientId,
'serverId' => $serverId,
'status' => self::STATUS_SUCCESS,
'contentState' => $this->_contentStateBackend->create(new Syncroton_Model_Content(array(
'device_id' => $this->_device,
'folder_id' => $collectionData->folder,
'contentid' => $serverId,
'creation_time' => $this->_syncTimeStamp,
'creation_synckey' => $collectionData->syncKey + 1
)))
);
} catch (Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " failed to add entry " . $e->getMessage());
$clientModifications['added'][] = array(
'clientId' => (string)$add->ClientId,
'status' => self::STATUS_SERVER_ERROR
);
}
}
}
// handle changes, but only if not first sync
if($collectionData->syncKey > 1 && $collectionData->hasClientChanges()) {
$changes = $collectionData->getClientChanges();
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($changes) . " entries to be updated on server");
foreach ($changes as $change) {
$serverId = (string)$change->ServerId;
try {
$dataController->updateEntry($collectionData->collectionId, $serverId, new $dataClass($change->ApplicationData));
$clientModifications['changed'][$serverId] = self::STATUS_SUCCESS;
} catch (Syncroton_Exception_AccessDenied $e) {
$clientModifications['changed'][$serverId] = self::STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT;
$clientModifications['forceChange'][$serverId] = $serverId;
} catch (Syncroton_Exception_NotFound $e) {
// entry does not exist anymore, will get deleted automaticaly
$clientModifications['changed'][$serverId] = self::STATUS_OBJECT_NOT_FOUND;
} catch (Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " failed to update entry " . $e);
// something went wrong while trying to update the entry
$clientModifications['changed'][$serverId] = self::STATUS_SERVER_ERROR;
}
}
}
// handle deletes, but only if not first sync
if($collectionData->hasClientDeletes()) {
$deletes = $collectionData->getClientDeletes();
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($deletes) . " entries to be deleted on server");
foreach ($deletes as $delete) {
$serverId = (string)$delete->ServerId;
try {
// check if we have sent this entry to the phone
$state = $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId);
try {
$dataController->deleteEntry($collectionData->collectionId, $serverId, $collectionData);
} catch(Syncroton_Exception_NotFound $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->crit(__METHOD__ . '::' . __LINE__ . ' tried to delete entry ' . $serverId . ' but entry was not found');
} catch (Syncroton_Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . ' tried to delete entry ' . $serverId . ' but a error occured: ' . $e->getMessage());
$clientModifications['forceAdd'][$serverId] = $serverId;
}
$this->_contentStateBackend->delete($state);
} catch (Syncroton_Exception_NotFound $senf) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . ' ' . $serverId . ' should have been removed from client already');
// should we send a special status???
//$collectionData->deleted[$serverId] = self::STATUS_SUCCESS;
}
$clientModifications['deleted'][$serverId] = self::STATUS_SUCCESS;
}
}
// handle fetches, but only if not first sync
if($collectionData->syncKey > 1 && $collectionData->hasClientFetches()) {
// the default value for GetChanges is 1. If the phone don't want the changes it must set GetChanges to 0
// some prevoius versions of iOS did not set GetChanges to 0 for fetches. Let's enforce getChanges to false here.
$collectionData->getChanges = false;
$fetches = $collectionData->getClientFetches();
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($fetches) . " entries to be fetched from server");
$toBeFecthed = array();
foreach ($fetches as $fetch) {
$serverId = (string)$fetch->ServerId;
$toBeFetched[$serverId] = $serverId;
}
$collectionData->toBeFetched = $toBeFetched;
}
$this->_collections[] = $collectionData;
$this->_modifications[$collectionData->collectionId] = $clientModifications;
}
}
/**
* (non-PHPdoc)
* @see Syncroton_Command_Wbxml::getResponse()
*/
public function getResponse()
{
$sync = $this->_outputDom->documentElement;
// provioning needed?
#if ($this->_statusProvisioning !== false) {
# if ($this->_logger instanceof Zend_Log)
# $this->_logger->info(__METHOD__ . '::' . __LINE__ . " provisiong needed or remote wipe requested");
# $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $this->_statusProvisioning));
#
# return $this->_outputDom;
#}
$collections = $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collections'));
foreach($this->_collections as $collectionData) {
// invalid collectionid provided
if (! ($collectionData->folder instanceof Syncroton_Model_IFolder)) {
$collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection'));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', 0));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_FOLDER_HIERARCHY_HAS_CHANGED));
// invalid synckey provided
} elseif (! ($collectionData->syncState instanceof Syncroton_Model_ISyncState)) {
// set synckey to 0
$collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection'));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', 0));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_INVALID_SYNC_KEY));
// initial sync
} elseif ($collectionData->syncState->counter === 0) {
$collectionData->syncState->counter++;
// initial sync
// send back a new SyncKey only
$collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection'));
if (!empty($collectionData->folder->class)) {
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Class', $collectionData->folder->class));
}
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', $collectionData->syncState->counter));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS));
} else {
$dataController = Syncroton_Data_Factory::factory($collectionData->folder->class , $this->_device, $this->_syncTimeStamp);
$clientModifications = $this->_modifications[$collectionData->collectionId];
$serverModifications = array(
'added' => array(),
'changed' => array(),
'deleted' => array(),
);
$moreAvailable = false;
if($collectionData->getChanges === true) {
// continue sync session?
if(is_array($collectionData->syncState->pendingdata)) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " restored from sync state ");
$serverModifications = $collectionData->syncState->pendingdata;
} else {
// fetch entries added since last sync
$allClientEntries = $this->_contentStateBackend->getFolderState($this->_device, $collectionData->folder);
$allServerEntries = $dataController->getServerEntries($collectionData->collectionId, $collectionData->options['filterType']);
// add entries
$serverDiff = array_diff($allServerEntries, $allClientEntries);
// add entries which produced problems during delete from client
$serverModifications['added'] = $clientModifications['forceAdd'];
// add entries not yet sent to client
$serverModifications['added'] = array_unique(array_merge($serverModifications['added'], $serverDiff));
# @todo still needed?
foreach($serverModifications['added'] as $id => $serverId) {
// skip entries added by client during this sync session
if(isset($clientModifications['added'][$serverId]) && !isset($clientModifications['forceAdd'][$serverId])) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped added entry: " . $serverId);
unset($serverModifications['added'][$id]);
}
}
// entries to be deleted
$serverModifications['deleted'] = array_diff($allClientEntries, $allServerEntries);
// fetch entries changed since last sync
$serverModifications['changed'] = $dataController->getChangedEntries($collectionData->collectionId, $collectionData->syncState->lastsync, $this->_syncTimeStamp);
$serverModifications['changed'] = array_merge($serverModifications['changed'], $clientModifications['forceChange']);
foreach($serverModifications['changed'] as $id => $serverId) {
// skip entry, if it got changed by client during current sync
if(isset($clientModifications['changed'][$serverId]) && !isset($clientModifications['forceChange'][$serverId])) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped changed entry: " . $serverId);
unset($serverModifications['changed'][$id]);
}
+ // skip entry, make sure we don't sent entries already added by client in this request
+ else if (isset($clientModifications['added'][$serverId]) && !isset($clientModifications['forceAdd'][$serverId])) {
+ if ($this->_logger instanceof Zend_Log)
+ $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped change for added entry: " . $serverId);
+ unset($serverModifications['changed'][$id]);
+ }
}
// entries comeing in scope are already in $serverModifications['added'] and do not need to
// be send with $serverCanges
$serverModifications['changed'] = array_diff($serverModifications['changed'], $serverModifications['added']);
}
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " found (added/changed/deleted) " . count($serverModifications['added']) . '/' . count($serverModifications['changed']) . '/' . count($serverModifications['deleted']) . ' entries for sync from server to client');
}
if (!empty($clientModifications['added']) || !empty($clientModifications['changed']) || !empty($clientModifications['deleted']) ||
!empty($serverModifications['added']) || !empty($serverModifications['changed']) || !empty($serverModifications['deleted'])) {
$collectionData->syncState->counter++;
}
// collection header
$collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection'));
if (!empty($collectionData->folder->class)) {
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Class', $collectionData->folder->class));
}
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', $collectionData->syncState->counter));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId));
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS));
$responses = $this->_outputDom->createElementNS('uri:AirSync', 'Responses');
// send reponse for newly added entries
if(!empty($clientModifications['added'])) {
foreach($clientModifications['added'] as $entryData) {
$add = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Add'));
$add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ClientId', $entryData['clientId']));
// we have no serverId is the add failed
if(isset($entryData['serverId'])) {
$add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $entryData['serverId']));
}
$add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $entryData['status']));
}
}
// send reponse for changed entries
if(!empty($clientModifications['changed'])) {
foreach($clientModifications['changed'] as $serverId => $status) {
if ($status !== Syncroton_Command_Sync::STATUS_SUCCESS) {
$change = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Change'));
$change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId));
$change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $status));
}
}
}
// send response for to be fetched entries
if(!empty($collectionData->toBeFetched)) {
foreach($collectionData->toBeFetched as $serverId) {
$fetch = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Fetch'));
$fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId));
try {
$applicationData = $this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData');
$dataController
->getEntry($collectionData, $serverId)
->appendXML($applicationData);
$fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS));
$fetch->appendChild($applicationData);
} catch (Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage());
$fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_OBJECT_NOT_FOUND));
}
}
}
if ($responses->hasChildNodes() === true) {
$collection->appendChild($responses);
}
if ((count($serverModifications['added']) + count($serverModifications['changed']) + count($serverModifications['deleted'])) > $collectionData->windowSize ) {
$moreAvailable = true;
$collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'MoreAvailable'));
}
$commands = $this->_outputDom->createElementNS('uri:AirSync', 'Commands');
/**
* process entries added on server side
*/
$newContentStates = array();
foreach($serverModifications['added'] as $id => $serverId) {
if($this->_totalCount === $collectionData->windowSize) {
break;
}
#/**
# * somewhere is a problem in the logic for handling moreAvailable
# *
# * it can happen, that we have a contentstate (which means we sent the entry to the client
# * and that this entry is yet in $collectionData->syncState->pendingdata['serverAdds']
# * I have no idea how this can happen, but the next lines of code work around this problem
# */
#try {
# $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId);
#
# if ($this->_logger instanceof Zend_Log)
# $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped an entry($serverId) which is already on the client");
#
# unset($serverModifications['added'][$id]);
# continue;
#
#} catch (Syncroton_Exception_NotFound $senf) {
# // do nothing => content state should not exist yet
#}
try {
$add = $this->_outputDom->createElementNS('uri:AirSync', 'Add');
$add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId));
$applicationData = $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData'));
$dataController
->getEntry($collectionData, $serverId)
->appendXML($applicationData);
$commands->appendChild($add);
$this->_totalCount++;
} catch (Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage());
}
// mark as send to the client, even the conversion to xml might have failed
$newContentStates[] = new Syncroton_Model_Content(array(
'device_id' => $this->_device,
'folder_id' => $collectionData->folder,
'contentid' => $serverId,
'creation_time' => $this->_syncTimeStamp,
'creation_synckey' => $collectionData->syncState->counter
));
unset($serverModifications['added'][$id]);
}
/**
* process entries changed on server side
*/
foreach($serverModifications['changed'] as $id => $serverId) {
if($this->_totalCount === $collectionData->windowSize) {
break;
}
try {
$change = $this->_outputDom->createElementNS('uri:AirSync', 'Change');
$change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId));
$applicationData = $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData'));
$dataController
->getEntry($collectionData, $serverId)
->appendXML($applicationData);
$commands->appendChild($change);
$this->_totalCount++;
} catch (Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage());
}
unset($serverModifications['changed'][$id]);
}
/**
* process entries deleted on server side
*/
$deletedContentStates = array();
foreach($serverModifications['deleted'] as $id => $serverId) {
if($this->_totalCount === $collectionData->windowSize) {
break;
}
try {
// check if we have sent this entry to the phone
$state = $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId);
$delete = $this->_outputDom->createElementNS('uri:AirSync', 'Delete');
$delete->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId));
$deletedContentStates[] = $state;
$commands->appendChild($delete);
$this->_totalCount++;
} catch (Exception $e) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage());
}
unset($serverModifications['deleted'][$id]);
}
if ($commands->hasChildNodes() === true) {
$collection->appendChild($commands);
}
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " new synckey is ". $collectionData->syncState->counter);
}
if (isset($collectionData->syncState) && $collectionData->syncState instanceof Syncroton_Model_ISyncState &&
$collectionData->syncState->counter != $collectionData->syncKey) {
// increment sync timestamp by 1 second
$this->_syncTimeStamp->modify('+1 sec');
// store pending data in sync state when needed
if(isset($moreAvailable) && $moreAvailable === true) {
$collectionData->syncState->pendingdata = array(
'added' => (array)$serverModifications['added'],
'changed' => (array)$serverModifications['changed'],
'deleted' => (array)$serverModifications['deleted']
);
} else {
$collectionData->syncState->pendingdata = null;
}
if (!empty($clientModifications['added'])) {
if ($this->_logger instanceof Zend_Log)
$this->_logger->info(__METHOD__ . '::' . __LINE__ . " remove previous synckey as client added new entries");
$keepPreviousSyncKey = false;
} else {
$keepPreviousSyncKey = true;
}
$collectionData->syncState->lastsync = $this->_syncTimeStamp;
try {
$transactionId = Syncroton_Registry::getTransactionManager()->startTransaction(Syncroton_Registry::getDatabase());
// store new synckey
$this->_syncStateBackend->create($collectionData->syncState, $keepPreviousSyncKey);
// store contentstates for new entries added to client
if (isset($newContentStates)) {
foreach($newContentStates as $state) {
$this->_contentStateBackend->create($state);
}
}
// remove contentstates for entries to be deleted on client
if (isset($deletedContentStates)) {
foreach($deletedContentStates as $state) {
$this->_contentStateBackend->delete($state);
}
}
Syncroton_Registry::getTransactionManager()->commitTransaction($transactionId);
} catch (Zend_Db_Statement_Exception $zdse) {
// something went wrong
// maybe another parallel request added a new synckey
// we must remove data added from client
if (!empty($clientModifications['added'])) {
foreach ($clientModifications['added'] as $added) {
$this->_contentStateBackend->delete($added['contentState']);
$dataController->deleteEntry($collectionData->collectionId, $added['serverId'], array());
}
}
Syncroton_Registry::getTransactionManager()->rollBack();
throw $zdse;
}
// store current filter type
try {
$folderState = $this->_folderBackend->getFolder($this->_device, $collectionData->collectionId);
$folderState->lastfiltertype = $collectionData->options['filterType'];
$this->_folderBackend->update($folderState);
} catch (Syncroton_Exception_NotFound $senf) {
// failed to get folderstate => should not happen but is also no problem in this state
if ($this->_logger instanceof Zend_Log)
$this->_logger->crit(__METHOD__ . '::' . __LINE__ . ' failed to get content state for: ' . $collectionData->collectionId);
}
}
}
return $this->_outputDom;
}
}
diff --git a/lib/kolab/kolab_storage_cache.php b/lib/kolab/kolab_storage_cache.php
index c3e88da..88f12a2 100644
--- a/lib/kolab/kolab_storage_cache.php
+++ b/lib/kolab/kolab_storage_cache.php
@@ -1,728 +1,729 @@
<?php
/**
* Kolab storage cache class providing a local caching layer for Kolab groupware objects.
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_storage_cache
{
private $db;
private $imap;
private $folder;
private $uid2msg;
private $objects;
private $index = array();
private $resource_uri;
private $enabled = true;
private $synched = false;
private $synclock = false;
private $ready = false;
private $max_sql_packet = 1046576; // 1 MB - 2000 bytes
private $binary_cols = array('photo','pgppublickey','pkcs7publickey');
/**
* Default constructor
*/
public function __construct(kolab_storage_folder $storage_folder = null)
{
$rcmail = rcube::get_instance();
$this->db = $rcmail->get_dbh();
$this->imap = $rcmail->get_storage();
$this->enabled = $rcmail->config->get('kolab_cache', false);
if ($this->enabled) {
// remove sync-lock on script termination
$rcmail->add_shutdown_function(array($this, '_sync_unlock'));
// read max_allowed_packet from mysql config
$this->max_sql_packet = min($this->db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; // mysql limit or max 4 MB
}
if ($storage_folder)
$this->set_folder($storage_folder);
}
/**
* Connect cache with a storage folder
*
* @param kolab_storage_folder The storage folder instance to connect with
*/
public function set_folder(kolab_storage_folder $storage_folder)
{
$this->folder = $storage_folder;
if (empty($this->folder->name)) {
$this->ready = false;
return;
}
// compose fully qualified ressource uri for this instance
$this->resource_uri = $this->folder->get_resource_uri();
$this->ready = $this->enabled;
}
/**
* Synchronize local cache data with remote
*/
public function synchronize()
{
// only sync once per request cycle
if ($this->synched)
return;
// increase time limit
@set_time_limit(500);
// lock synchronization for this folder or wait if locked
$this->_sync_lock();
// synchronize IMAP mailbox cache
$this->imap->folder_sync($this->folder->name);
// compare IMAP index with object cache index
$imap_index = $this->imap->index($this->folder->name);
$this->index = $imap_index->get();
// determine objects to fetch or to invalidate
if ($this->ready) {
// read cache index
$sql_result = $this->db->query(
"SELECT msguid, uid FROM kolab_cache WHERE resource=? AND type<>?",
$this->resource_uri,
'lock'
);
$old_index = array();
while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
$old_index[] = $sql_arr['msguid'];
$this->uid2msg[$sql_arr['uid']] = $sql_arr['msguid'];
}
// fetch new objects from imap
foreach (array_diff($this->index, $old_index) as $msguid) {
if ($object = $this->folder->read_object($msguid, '*')) {
$this->_extended_insert($msguid, $object);
}
}
$this->_extended_insert(0, null);
// delete invalid entries from local DB
$del_index = array_diff($old_index, $this->index);
if (!empty($del_index)) {
$quoted_ids = join(',', array_map(array($this->db, 'quote'), $del_index));
$this->db->query(
"DELETE FROM kolab_cache WHERE resource=? AND msguid IN ($quoted_ids)",
$this->resource_uri
);
}
}
// remove lock
$this->_sync_unlock();
$this->synched = time();
}
/**
* Read a single entry from cache or from IMAP directly
*
* @param string Related IMAP message UID
* @param string Object type to read
* @param string IMAP folder name the entry relates to
* @param array Hash array with object properties or null if not found
*/
public function get($msguid, $type = null, $foldername = null)
{
// delegate to another cache instance
if ($foldername && $foldername != $this->folder->name) {
return kolab_storage::get_folder($foldername)->cache->get($msguid, $object);
}
// load object if not in memory
if (!isset($this->objects[$msguid])) {
if ($this->ready) {
$sql_result = $this->db->query(
"SELECT * FROM kolab_cache ".
"WHERE resource=? AND type=? AND msguid=?",
$this->resource_uri,
$type ?: $this->folder->type,
$msguid
);
if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
$this->objects[$msguid] = $this->_unserialize($sql_arr);
}
}
// fetch from IMAP if not present in cache
if (empty($this->objects[$msguid])) {
$result = $this->_fetch(array($msguid), $type, $foldername);
$this->objects[$msguid] = $result[0];
}
}
return $this->objects[$msguid];
}
/**
* Insert/Update a cache entry
*
* @param string Related IMAP message UID
* @param mixed Hash array with object properties to save or false to delete the cache entry
* @param string IMAP folder name the entry relates to
*/
public function set($msguid, $object, $foldername = null)
{
if (!$msguid) {
return;
}
// delegate to another cache instance
if ($foldername && $foldername != $this->folder->name) {
kolab_storage::get_folder($foldername)->cache->set($msguid, $object);
return;
}
// remove old entry
if ($this->ready) {
$this->db->query("DELETE FROM kolab_cache WHERE resource=? AND msguid=? AND type<>?",
$this->resource_uri, $msguid, 'lock');
}
if ($object) {
// insert new object data...
$this->insert($msguid, $object);
}
else {
// ...or set in-memory cache to false
$this->objects[$msguid] = $object;
}
}
/**
* Insert a cache entry
*
* @param string Related IMAP message UID
* @param mixed Hash array with object properties to save or false to delete the cache entry
*/
public function insert($msguid, $object)
{
// write to cache
if ($this->ready) {
$sql_data = $this->_serialize($object);
$objtype = $object['_type'] ? $object['_type'] : $this->folder->type;
$result = $this->db->query(
"INSERT INTO kolab_cache ".
" (resource, type, msguid, uid, created, changed, data, xml, dtstart, dtend, tags, words)".
" VALUES (?, ?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?, ?, ?, ?)",
$this->resource_uri,
$objtype,
$msguid,
$object['uid'],
$sql_data['changed'],
$sql_data['data'],
$sql_data['xml'],
$sql_data['dtstart'],
$sql_data['dtend'],
$sql_data['tags'],
$sql_data['words']
);
if (!$this->db->affected_rows($result)) {
rcube::raise_error(array(
'code' => 900, 'type' => 'php',
'message' => "Failed to write to kolab cache"
), true);
}
}
// keep a copy in memory for fast access
$this->objects[$msguid] = $object;
$this->uid2msg[$object['uid']] = $msguid;
}
/**
* Move an existing cache entry to a new resource
*
* @param string Entry's IMAP message UID
* @param string Entry's Object UID
* @param string Target IMAP folder to move it to
*/
public function move($msguid, $objuid, $target_folder)
{
$target = kolab_storage::get_folder($target_folder);
// resolve new message UID in target folder
if ($new_msguid = $target->cache->uid2msguid($objuid)) {
$this->db->query(
"UPDATE kolab_cache SET resource=?, msguid=? ".
"WHERE resource=? AND msguid=? AND type<>?",
$target->get_resource_uri(),
$new_msguid,
$this->resource_uri,
$msguid,
'lock'
);
}
else {
// just clear cache entry
$this->set($msguid, false);
}
unset($this->uid2msg[$uid]);
}
/**
* Remove all objects from local cache
*/
public function purge($type = null)
{
$result = $this->db->query(
"DELETE FROM kolab_cache WHERE resource=?".
($type ? ' AND type=?' : ''),
$this->resource_uri,
$type
);
return $this->db->affected_rows($result);
}
/**
* Select Kolab objects filtered by the given query
*
* @param array Pseudo-SQL query as list of filter parameter triplets
* triplet: array('<colname>', '<comparator>', '<value>')
* @param boolean Set true to only return UIDs instead of complete objects
* @return array List of Kolab data objects (each represented as hash array) or UIDs
*/
public function select($query = array(), $uids = false)
{
$result = array();
// read from local cache DB (assume it to be synchronized)
if ($this->ready) {
$sql_result = $this->db->query(
"SELECT " . ($uids ? 'msguid, uid' : '*') . " FROM kolab_cache ".
"WHERE resource=? " . $this->_sql_where($query),
$this->resource_uri
);
while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
if ($uids) {
$this->uid2msg[$sql_arr['uid']] = $sql_arr['msguid'];
$result[] = $sql_arr['uid'];
}
else if ($object = $this->_unserialize($sql_arr)) {
$result[] = $object;
}
}
}
else {
// extract object type from query parameter
$filter = $this->_query2assoc($query);
// use 'list' for folder's default objects
if ($filter['type'] == $this->type) {
$index = $this->index;
}
else { // search by object type
$search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type'];
$index = $this->imap->search_once($this->folder->name, $search)->get();
}
// fetch all messages in $index from IMAP
$result = $uids ? $this->_fetch_uids($index, $filter['type']) : $this->_fetch($index, $filter['type']);
// TODO: post-filter result according to query
}
return $result;
}
/**
* Get number of objects mathing the given query
*
* @param array $query Pseudo-SQL query as list of filter parameter triplets
* @return integer The number of objects of the given type
*/
public function count($query = array())
{
$count = 0;
// cache is in sync, we can count records in local DB
if ($this->synched) {
$sql_result = $this->db->query(
"SELECT COUNT(*) AS numrows FROM kolab_cache ".
"WHERE resource=? " . $this->_sql_where($query),
$this->resource_uri
);
$sql_arr = $this->db->fetch_assoc($sql_result);
$count = intval($sql_arr['numrows']);
}
else {
// search IMAP by object type
$filter = $this->_query2assoc($query);
$ctype = kolab_format::KTYPE_PREFIX . $filter['type'];
$index = $this->imap->search_once($this->folder->name, 'UNDELETED HEADER X-Kolab-Type ' . $ctype);
$count = $index->count();
}
return $count;
}
/**
* Helper method to compose a valid SQL query from pseudo filter triplets
*/
private function _sql_where($query)
{
$sql_where = '';
+
foreach ($query as $param) {
if ($param[1] == '=' && is_array($param[2])) {
$qvalue = '(' . join(',', array_map(array($this->db, 'quote'), $param[2])) . ')';
$param[1] = 'IN';
}
else if ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') {
$not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : '';
$param[1] = $not . 'LIKE';
$qvalue = $this->db->quote('%'.preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%');
}
else if ($param[0] == 'tags') {
$param[1] = 'LIKE';
$qvalue = $this->db->quote('% '.$param[2].' %');
}
else {
$qvalue = $this->db->quote($param[2]);
}
$sql_where .= sprintf(' AND %s %s %s',
$this->db->quote_identifier($param[0]),
$param[1],
$qvalue
);
}
return $sql_where;
}
/**
* Helper method to convert the given pseudo-query triplets into
* an associative filter array with 'equals' values only
*/
private function _query2assoc($query)
{
// extract object type from query parameter
$filter = array();
foreach ($query as $param) {
if ($param[1] == '=')
$filter[$param[0]] = $param[2];
}
return $filter;
}
/**
* Fetch messages from IMAP
*
* @param array List of message UIDs to fetch
* @param string Requested object type or * for all
* @param string IMAP folder to read from
* @return array List of parsed Kolab objects
*/
private function _fetch($index, $type = null, $folder = null)
{
$results = array();
foreach ((array)$index as $msguid) {
if ($object = $this->folder->read_object($msguid, $type, $folder)) {
$results[] = $object;
$this->set($msguid, $object);
}
}
return $results;
}
/**
* Fetch object UIDs (aka message subjects) from IMAP
*
* @param array List of message UIDs to fetch
* @param string Requested object type or * for all
* @param string IMAP folder to read from
* @return array List of parsed Kolab objects
*/
private function _fetch_uids($index, $type = null)
{
if (!$type)
$type = $this->folder->type;
$results = array();
foreach ((array)$this->imap->fetch_headers($this->folder->name, $index, false) as $msguid => $headers) {
$object_type = kolab_format::mime2object_type($headers->others['x-kolab-type']);
// check object type header and abort on mismatch
if ($type != '*' && $object_type != $type)
return false;
$uid = $headers->subject;
$this->uid2msg[$uid] = $msguid;
$results[] = $uid;
}
return $results;
}
/**
* Helper method to convert the given Kolab object into a dataset to be written to cache
*/
private function _serialize($object)
{
$bincols = array_flip($this->binary_cols);
$sql_data = array('changed' => null, 'dtstart' => null, 'dtend' => null, 'xml' => '', 'tags' => '', 'words' => '');
$objtype = $object['_type'] ? $object['_type'] : $this->folder->type;
// set type specific values
if ($objtype == 'event') {
// database runs in server's timezone so using date() is what we want
$sql_data['dtstart'] = date('Y-m-d H:i:s', is_object($object['start']) ? $object['start']->format('U') : $object['start']);
$sql_data['dtend'] = date('Y-m-d H:i:s', is_object($object['end']) ? $object['end']->format('U') : $object['end']);
// extend date range for recurring events
if ($object['recurrence']) {
$recurrence = new kolab_date_recurrence($object);
$sql_data['dtend'] = date('Y-m-d 23:59:59', $recurrence->end() ?: strtotime('now +1 year'));
}
}
else if ($objtype == 'task') {
if ($object['start'])
$sql_data['dtstart'] = date('Y-m-d H:i:s', is_object($object['start']) ? $object['start']->format('U') : $object['start']);
if ($object['due'])
$sql_data['dtend'] = date('Y-m-d H:i:s', is_object($object['due']) ? $object['due']->format('U') : $object['due']);
}
if ($object['changed']) {
$sql_data['changed'] = date('Y-m-d H:i:s', is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']);
}
if ($object['_formatobj']) {
$sql_data['xml'] = preg_replace('!(</?[a-z0-9:-]+>)[\n\r\t\s]+!ms', '$1', (string)$object['_formatobj']->write());
$sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search
$sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' ';
}
// extract object data
$data = array();
foreach ($object as $key => $val) {
if ($val === "" || $val === null) {
// skip empty properties
continue;
}
if (isset($bincols[$key])) {
$data[$key] = base64_encode($val);
}
else if ($key[0] != '_') {
$data[$key] = $val;
}
else if ($key == '_attachments') {
foreach ($val as $k => $att) {
unset($att['content'], $att['path']);
if ($att['id'])
$data[$key][$k] = $att;
}
}
}
$sql_data['data'] = serialize($data);
return $sql_data;
}
/**
* Helper method to turn stored cache data into a valid storage object
*/
private function _unserialize($sql_arr)
{
$object = unserialize($sql_arr['data']);
// decode binary properties
foreach ($this->binary_cols as $key) {
if (!empty($object[$key]))
$object[$key] = base64_decode($object[$key]);
}
// add meta data
$object['_type'] = $sql_arr['type'];
$object['_msguid'] = $sql_arr['msguid'];
$object['_mailbox'] = $this->folder->name;
$object['_formatobj'] = kolab_format::factory($sql_arr['type'], $sql_arr['xml']);
return $object;
}
/**
* Write records into cache using extended inserts to reduce the number of queries to be executed
*
* @param int Message UID. Set 0 to commit buffered inserts
* @param array Kolab object to cache
*/
private function _extended_insert($msguid, $object)
{
static $buffer = '';
$line = '';
if ($object) {
$sql_data = $this->_serialize($object);
$objtype = $object['_type'] ? $object['_type'] : $this->folder->type;
$values = array(
$this->db->quote($this->resource_uri),
$this->db->quote($objtype),
$this->db->quote($msguid),
$this->db->quote($object['uid']),
$this->db->now(),
$this->db->quote($sql_data['changed']),
$this->db->quote($sql_data['data']),
$this->db->quote($sql_data['xml']),
$this->db->quote($sql_data['dtstart']),
$this->db->quote($sql_data['dtend']),
$this->db->quote($sql_data['tags']),
$this->db->quote($sql_data['words']),
);
$line = '(' . join(',', $values) . ')';
}
if ($buffer && (!$msguid || (strlen($buffer) + strlen($line) > $this->max_sql_packet))) {
$result = $this->db->query(
"INSERT INTO kolab_cache ".
" (resource, type, msguid, uid, created, changed, data, xml, dtstart, dtend, tags, words)".
" VALUES $buffer"
);
if (!$this->db->affected_rows($result)) {
rcube::raise_error(array(
'code' => 900, 'type' => 'php',
'message' => "Failed to write to kolab cache"
), true);
}
$buffer = '';
}
$buffer .= ($buffer ? ',' : '') . $line;
}
/**
* Check lock record for this folder and wait if locked or set lock
*/
private function _sync_lock()
{
if (!$this->ready)
return;
$sql_arr = $this->db->fetch_assoc($this->db->query(
"SELECT msguid AS locked, ".$this->db->unixtimestamp('created')." AS created FROM kolab_cache ".
"WHERE resource=? AND type=?",
$this->resource_uri,
'lock'
));
// abort if database is not set-up
if ($this->db->is_error()) {
$this->ready = false;
return;
}
$this->synclock = true;
// create lock record if not exists
if (!$sql_arr) {
$this->db->query(
"INSERT INTO kolab_cache (resource, type, msguid, created, uid, data, xml)".
" VALUES (?, ?, 1, ?, '', '', '')",
$this->resource_uri,
'lock',
date('Y-m-d H:i:s')
);
}
// wait if locked (expire locks after 10 minutes)
else if (intval($sql_arr['locked']) > 0 && (time() - $sql_arr['created']) < 600) {
usleep(500000);
return $this->_sync_lock();
}
// set lock
else {
$this->db->query(
"UPDATE kolab_cache SET msguid=1, created=? ".
"WHERE resource=? AND type=?",
date('Y-m-d H:i:s'),
$this->resource_uri,
'lock'
);
}
}
/**
* Remove lock for this folder
*/
public function _sync_unlock()
{
if (!$this->ready || !$this->synclock)
return;
$this->db->query(
"UPDATE kolab_cache SET msguid=0 ".
"WHERE resource=? AND type=?",
$this->resource_uri,
'lock'
);
$this->synclock = false;
}
/**
* Resolve an object UID into an IMAP message UID
*
* @param string Kolab object UID
* @param boolean Include deleted objects
* @return int The resolved IMAP message UID
*/
public function uid2msguid($uid, $deleted = false)
{
if (!isset($this->uid2msg[$uid])) {
// use IMAP SEARCH to get the right message
$index = $this->imap->search_once($this->folder->name, ($deleted ? '' : 'UNDELETED ') . 'HEADER SUBJECT ' . $uid);
$results = $index->get();
$this->uid2msg[$uid] = $results[0];
}
return $this->uid2msg[$uid];
}
}
diff --git a/lib/kolab_sync_backend.php b/lib/kolab_sync_backend.php
index cdfc8f7..d9413fa 100644
--- a/lib/kolab_sync_backend.php
+++ b/lib/kolab_sync_backend.php
@@ -1,897 +1,951 @@
<?php
/**
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
class kolab_sync_backend
{
/**
* Singleton instace of kolab_sync_backend
*
* @var kolab_sync_backend
*/
static protected $instance;
protected $storage;
protected $folder_meta;
protected $folder_uids;
protected $root_meta;
static protected $types = array(
1 => '',
2 => 'mail.inbox',
3 => 'mail.drafts',
4 => 'mail.wastebasket',
5 => 'mail.sentitems',
6 => 'mail.outbox',
7 => 'task.default',
8 => 'event.default',
9 => 'contact.default',
10 => 'note.default',
11 => 'journal.default',
12 => 'mail',
13 => 'event',
14 => 'contact',
15 => 'task',
16 => 'journal',
17 => 'note',
);
static protected $classes = array(
Syncroton_Data_Factory::CLASS_CALENDAR => 'event',
Syncroton_Data_Factory::CLASS_CONTACTS => 'contact',
Syncroton_Data_Factory::CLASS_EMAIL => 'mail',
Syncroton_Data_Factory::CLASS_TASKS => 'task',
);
const ROOT_MAILBOX = 'INBOX';
// const ROOT_MAILBOX = '';
const ASYNC_KEY = '/private/vendor/kolab/activesync';
const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
/**
* This implements the 'singleton' design pattern
*
* @return kolab_sync_backend The one and only instance
*/
static function get_instance()
{
if (!self::$instance) {
self::$instance = new kolab_sync_backend;
self::$instance->startup(); // init AFTER object was linked with self::$instance
}
return self::$instance;
}
/**
* Class initialization
*/
public function startup()
{
$this->storage = rcube::get_instance()->get_storage();
// @TODO: reset cache? if we do this for every request the cache would be useless
// There's no session here
//$this->storage->clear_cache('mailboxes.', true);
// set additional header used by libkolab
$this->storage->set_options(array(
// @TODO: there can be Roundcube plugins defining additional headers,
// we maybe would need to add them here
'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION',
'skip_deleted' => true,
'threading' => false,
));
// Disable paging
$this->storage->set_pagesize(999999);
}
/**
* List known devices
*
* @return array Device list as hash array
*/
public function devices_list()
{
if ($this->root_meta === null) {
// @TODO: consider server annotation instead of INBOX
if ($meta = $this->storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) {
$this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]);
}
else {
$this->root_meta = array();
}
}
if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) {
return $this->root_meta['DEVICE'];
}
return array();
}
/**
* Get list of folders available for sync
*
* @param string $deviceid Device identifier
* @param string $type Folder type
*
* @return array List of mailbox folders
*/
public function folders_list($deviceid, $type)
{
$folders_list = array();
// get all folders of specified type
$folders = (array) kolab_storage::list_folders('', '*', $type, false, $typedata);
// get folders activesync config
$folderdata = $this->folder_meta();
// check if folders are "subscribed" for activesync
foreach ($folderdata as $folder => $meta) {
if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid])
|| empty($meta['FOLDER'][$deviceid]['S'])
) {
continue;
}
if (!empty($type) && !in_array($folder, $folders)) {
continue;
}
// Activesync folder identifier (serverId)
$folder_id = self::folder_id($folder, $typedata[$folder]);
$folders_list[$folder_id] = $this->folder_data($folder, $typedata[$folder]);
}
return $folders_list;
}
/**
* Getter for folder metadata
*
* @return array Hash array with meta data for each folder
*/
public function folder_meta()
{
if (!isset($this->folder_meta)) {
$this->folder_meta = array();
// get folders activesync config
$folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY);
foreach ($folderdata as $folder => $meta) {
if ($asyncdata = $meta[self::ASYNC_KEY]) {
if ($metadata = $this->unserialize_metadata($asyncdata)) {
$this->folder_meta[$folder] = $metadata;
}
}
}
}
return $this->folder_meta;
}
/**
* Creates folder and subscribes to the device
*
* @param string $name Folder name (UTF7-IMAP)
* @param int $type Folder (ActiveSync) type
* @param string $deviceid Device identifier
*
* @return bool True on success, False on failure
*/
public function folder_create($name, $type, $deviceid)
{
if ($this->storage->folder_exists($name)) {
$created = true;
}
else {
$type = self::type_activesync2kolab($type);
$created = kolab_storage::folder_create($name, $kolab_type);
}
if ($created) {
// Set ActiveSync subscription flag
$this->folder_set($name, $deviceid, 1);
return true;
}
return false;
}
/**
* Renames a folder
*
* @param string $old_name Old folder name (UTF7-IMAP)
* @param string $new_name New folder name (UTF7-IMAP)
* @param int $type Folder (ActiveSync) type
* @param string $deviceid Device identifier
*
* @return bool True on success, False on failure
*/
public function folder_rename($old_name, $new_name, $type, $deviceid)
{
$type = self::type_activesync2kolab($type);
$moved = kolab_storage::folder_rename($old_name, $new_name);
if ($moved) {
// UnSet ActiveSync subscription flag
$this->folder_set($old_name, $deviceid, 0);
// Set ActiveSync subscription flag
$this->folder_set($new_name, $deviceid, 1);
return true;
}
return false;
}
/**
* Deletes folder
*
* @param string $name Folder name (UTF7-IMAP)
* @param string $deviceid Device identifier
*
*/
public function folder_delete($name, $deviceid)
{
unset($this->folder_meta[$name]);
return kolab_storage::folder_delete($name);
}
/**
* Sets ActiveSync subscription flag on a folder
*
* @param string $name Folder name (UTF7-IMAP)
* @param string $deviceid Device identifier
* @param int $flag Flag value (0|1|2)
*/
public function folder_set($name, $deviceid, $flag)
{
// get folders activesync config
$metadata = $this->folder_meta();
$metadata = $metadata[$name];
if ($flag) {
if (empty($metadata)) {
$metadata = array();
}
if (empty($metadata['FOLDER'])) {
$metadata['FOLDER'] = array();
}
if (empty($metadata['FOLDER'][$deviceid])) {
$metadata['FOLDER'][$deviceid] = array();
}
// Z-Push uses:
// 1 - synchronize, no alarms
// 2 - synchronize with alarms
$metadata['FOLDER'][$deviceid]['S'] = $flag;
}
if (!$flag) {
unset($metadata['FOLDER'][$deviceid]['S']);
if (empty($metadata['FOLDER'][$deviceid])) {
unset($metadata['FOLDER'][$deviceid]);
}
if (empty($metadata['FOLDER'])) {
unset($metadata['FOLDER']);
}
if (empty($metadata)) {
$metadata = null;
}
}
// Return if nothing's been changed
if (!self::data_array_diff($this->folder_meta[$name], $metadata)) {
return true;
}
$this->folder_meta[$name] = $metadata;
return $this->storage->set_metadata($name, array(
self::ASYNC_KEY => $this->serialize_metadata($metadata)));
}
public function device_get($id)
{
$devices_list = $this->devices_list();
$result = $devices_list[$id];
return $result;
}
/**
* Registers new device on server
*
* @param array $device Device data
* @param string $id Device ID
*
* @return bool True on success, False on failure
*/
public function device_create($device, $id)
{
// Fill local cache
$this->devices_list();
// Old Kolab_ZPush device parameters
// MODE: -1 | 0 | 1 (not set | flatmode | foldermode)
// TYPE: device type string
// ALIAS: user-friendly device name
// Syncroton (kolab_sync_backend_device) uses
// ID: internal identifier in syncroton database
// TYPE: device type string
$metadata = $this->root_meta;
$metadata['DEVICE'][$id] = $device;
$metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata));
$result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata);
if ($result) {
// Update local cache
$this->root_meta['DEVICE'][$id] = $device;
// Subscribe to default folders
$foldertypes = $this->storage->get_metadata('*', kolab_storage::CTYPE_KEY);
$folders = array('INBOX');
$types = array(
'mail.drafts',
'mail.wastebasket',
'mail.sentitems',
'mail.outbox',
'event.default',
'contact.default',
);
$foldertypes = array_map('implode', $foldertypes);
$foldertypes = array_intersect($foldertypes, $types);
// get default folders
foreach ($foldertypes as $folder => $type) {
// only personal folders
if ($this->storage->folder_namespace($folder) == 'personal') {
$this->folder_set($folder, $id, 1);
}
}
}
return $result;
}
public function device_update($device, $id)
{
$devices_list = $this->devices_list();
$old_device = $devices_list[$id];
if (!$old_device) {
return false;
}
// Do nothing if nothing is changed
if (!self::data_array_diff($old_device, $device)) {
return true;
}
$device = array_merge($old_device, $device);
$metadata = $this->root_meta;
$metadata['DEVICE'][$id] = $device;
$metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata));
$result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata);
if ($result) {
// Update local cache
$this->root_meta['DEVICE'][$id] = $device;
}
return $result;
}
/**
* Device delete.
*
* @param string $id Device ID
*
* @return bool True on success, False on failure
*/
public function device_delete($id)
{
$device = $this->device_get($id);
if (!$device) {
return false;
}
unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]);
if (empty($this->root_meta['DEVICE'])) {
unset($this->root_meta['DEVICE']);
}
if (empty($this->root_meta['FOLDER'])) {
unset($this->root_meta['FOLDER']);
}
$metadata = $this->serialize_metadata($this->root_meta);
$metadata = array(self::ASYNC_KEY => $metadata);
// update meta data
$result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata);
if ($result) {
// remove device annotation for every folder
foreach ($this->folder_meta() as $folder => $meta) {
// skip root folder (already handled above)
if ($folder == self::ROOT_MAILBOX)
continue;
if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) {
unset($meta['FOLDER'][$id]);
if (empty($meta['FOLDER'])) {
unset($this->folder_meta[$folder]['FOLDER']);
unset($meta['FOLDER']);
}
if (empty($meta)) {
unset($this->folder_meta[$folder]);
$meta = null;
}
$metadata = array(self::ASYNC_KEY => $this->serialize_metadata($meta));
$res = $this->storage->set_metadata($folder, $metadata);
if ($res && $meta) {
$this->folder_meta[$folder] = $meta;
}
}
}
}
return $result;
}
/**
* Helper method to decode saved IMAP metadata
*/
private function unserialize_metadata($str)
{
if (!empty($str)) {
// Support old Z-Push annotation format
if ($str[0] != '{') {
$str = base64_decode($str);
}
$data = json_decode($str, true);
return $data;
}
return null;
}
/**
* Helper method to encode IMAP metadata for saving
*/
private function serialize_metadata($data)
{
if (!empty($data) && is_array($data)) {
$data = json_encode($data);
// $data = base64_encode($data);
return $data;
}
return null;
}
/**
* Returns Kolab folder type for specified ActiveSync type ID
*/
public static function type_activesync2kolab($type)
{
if (!empty(self::$types[$type])) {
return self::$types[$type];
}
return '';
}
/**
* Returns ActiveSync folder type for specified Kolab type
*/
public static function type_kolab2activesync($type)
{
if ($key = array_search($type, self::$types)) {
return $key;
}
return key(self::$types);
}
/**
* Returns Kolab folder type for specified ActiveSync class name
*/
public static function class_activesync2kolab($class)
{
if (!empty(self::$classes[$class])) {
return self::$classes[$class];
}
return '';
}
private function folder_data($folder, $type)
{
// Folder name parameters
$delim = $this->storage->get_hierarchy_delimiter();
$items = explode($delim, $folder);
$name = array_pop($items);
// Folder UID
$folder_id = $this->folder_id($folder);
// Syncroton folder data array
return array(
'folderid' => $folder_id,
'parentid' => count($items) ? self::folder_id(implode($delim, $items)) : 0,
'displayname' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET),
'type' => self::type_kolab2activesync($type),
);
}
/**
* Builds folder ID based on folder name
*/
public function folder_id($name, $type = null)
{
// ActiveSync expects folder identifiers to be max.64 characters
// So we can't use just folder name
if ($name === '' || !is_string($name)) {
return null;
}
if (isset($this->folder_uids[$name])) {
return $this->folder_uids[$name];
}
/*
@TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves.
There's one inconvenience of this solution: folder name/type change
would be handled in ActiveSync as delete + create.
// get folders unique identifier
$folderdata = $this->storage->get_metadata($name, self::UID_KEY);
if ($folderdata && !empty($folderdata[$name])) {
$uid = $folderdata[$name][self::UID_KEY];
$this->folder_uids[$name] = $uid;
return $uid;
}
*/
// Add type to folder UID hash, so type change can be detected by Syncroton
if (!$type) {
$metadata = $this->storage->get_metadata($name, kolab_storage::CTYPE_KEY);
$type = $metadata[$name][kolab_storage::CTYPE_KEY];
}
$uid = md5($name . '!!' . $type);
return $this->folder_uids[$name] = $uid;
return $uid;
}
/**
* Returns IMAP folder name
*
* @param string $id Folder identifier
* @param string $deviceid Device dentifier
*
* @return string Folder name (UTF7-IMAP)
*/
public function folder_id2name($id, $deviceid)
{
// check in cache first
if (!empty($this->folder_uids)) {
if (($name = array_search($id, $this->folder_uids)) !== false) {
return $name;
}
}
/*
@TODO: see folder_id()
// get folders unique identifier
$folderdata = $this->storage->get_metadata('*', self::UID_KEY);
foreach ((array)$folderdata as $folder => $data) {
if (!empty($data[self::UID_KEY])) {
$uid = $data[self::UID_KEY];
$this->folder_uids[$folder] = $uid;
if ($uid == $id) {
$name = $folder;
}
}
}
*/
// get all folders of specified type
$folderdata = $this->folder_meta();
// check if folders are "subscribed" for activesync
foreach ($folderdata as $folder => $meta) {
if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid])
|| empty($meta['FOLDER'][$deviceid]['S'])
) {
continue;
}
$uid = self::folder_id($folder);
$this->folder_uids[$folder] = $uid;
if ($uid == $id) {
$name = $folder;
}
}
return $name;
}
/**
* Compares two arrays
*
* @param array $array1
* @param array $array2
*
* @return bool True if arrays differs, False otherwise
*/
private static function data_array_diff($array1, $array2)
{
if (!is_array($array1) || !is_array($array2)) {
return $array1 != $array2;
}
if (count($array1) != count($array2)) {
return true;
}
foreach ($array1 as $key => $val) {
if (!array_key_exists($key, $array2)) {
return true;
}
if ($val !== $array2[$key]) {
return true;
}
}
return false;
}
/**
* Send the given message using the configured method.
*
* @param string $message Complete message source
* @param array $smtp_error SMTP error array (reference)
* @param array $smtp_opts SMTP options (e.g. DSN request)
*
* @return boolean Send status.
*/
public function send_message(&$message, &$smtp_error, $smtp_opts = null)
{
$rcube = rcube::get_instance();
- list($headers, $message) = preg_split('/\r?\n\r?\n/', $message, 2, PREG_SPLIT_NO_EMPTY);
-
- // Parse headers to get sender and recipients
- $headers = str_replace("\r\n", "\n", $headers);
- $headers = explode("\n", trim($headers));
-
- $ln = 0;
- $lines = array();
-
- foreach ($headers as $line) {
- if (ord($line[0]) <= 32) {
- $lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($line);
- }
- else {
- $lines[++$ln] = trim($line);
- }
- }
-
- $headers = array();
- $headers_map = array(
- 'subject' => 'Subject',
- 'from' => 'From',
- 'to' => 'To',
- 'cc' => 'Cc',
- 'bcc' => 'Bcc',
- );
-
- foreach ($lines as $line) {
- list($field, $string) = explode(':', $line, 2);
- $_field = strtolower($field);
-
- if (isset($headers_map[$_field])) {
- $field = $headers_map[$_field];
- }
-
- $headers[$field] = trim($string);
- }
+ list($headers, $message) = $this->parse_mime($message);
$mailto = $headers['To'];
$headers['User-Agent'] .= $rcube->app_name;
if ($agent = $rcube->config->get('useragent')) {
$headers['User-Agent'] .= '/' . $agent;
}
// send thru SMTP server using custom SMTP library
if ($rcube->config->get('smtp_server')) {
$smtp_headers = $headers;
// generate list of recipients
$recipients = array();
if (!empty($headers['To']))
$recipients[] = $headers['To'];
if (!empty($headers['Cc']))
$recipients[] = $headers['Cc'];
if (!empty($headers['Bcc']))
$recipients[] = $headers['Bcc'];
// remove Bcc header
unset($smtp_headers['Bcc']);
// send message
if (!is_object($rcube->smtp)) {
$rcube->smtp_init(true);
}
$sent = $rcube->smtp->send_mail($headers['From'], $recipients, $smtp_headers, $message, $smtp_opts);
$smtp_response = $rcube->smtp->get_response();
$smtp_error = $rcube->smtp->get_error();
// log error
if (!$sent) {
rcube::raise_error(array('code' => 800, 'type' => 'smtp',
'line' => __LINE__, 'file' => __FILE__,
'message' => "SMTP error: ".join("\n", $smtp_response)), true, false);
}
}
// send mail using PHP's mail() function
else {
$mail_headers = $headers;
$delim = $rcube->config->header_delimiter();
$subject = $headers['Subject'];
$to = $headers['To'];
// unset some headers because they will be added by the mail() function
unset($mail_headers['To'], $mail_headers['Subject']);
// #1485779
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
if (preg_match_all('/<([^@]+@[^>]+)>/', $to, $m)) {
$to = implode(', ', $m[1]);
}
}
foreach ($mail_headers as $header => $header_value) {
$mail_headers[$header] = $header . ': ' . $header_value;
}
$header_str = rtrim(implode("\r\n", $mail_headers));
if ($delim != "\r\n") {
$header_str = str_replace("\r\n", $delim, $header_str);
$msg_body = str_replace("\r\n", $delim, $message);
$to = str_replace("\r\n", $delim, $to);
$subject = str_replace("\r\n", $delim, $subject);
}
if (ini_get('safe_mode'))
$sent = mail($to, $subject, $message, $header_str);
else
$sent = mail($to, $subject, $message, $header_str, "-f$from");
}
if ($sent) {
$rcube->plugins->exec_hook('message_sent', array('headers' => $headers, 'body' => $message));
// remove MDN headers after sending
unset($headers['Return-Receipt-To'], $headers['Disposition-Notification-To']);
// get all recipients
if ($headers['Cc'])
$mailto .= ' ' . $headers['Cc'];
if ($headers['Bcc'])
$mailto .= ' ' . $headers['Bcc'];
if (preg_match_all('/<([^@]+@[^>]+)>/', $mailto, $m))
$mailto = implode(', ', array_unique($m[1]));
if ($rcube->config->get('smtp_log')) {
rcube::write_log('sendmail', sprintf("User %s [%s]; Message for %s; %s",
$rcube->get_user_name(),
$_SERVER['REMOTE_ADDR'],
$mailto,
!empty($smtp_response) ? join('; ', $smtp_response) : ''));
}
}
unset($headers['Bcc']);
// Build the message back
foreach ($headers as $header => $header_value) {
$headers[$header] = $header . ': ' . $header_value;
}
$message = trim(implode("\r\n", $headers)) . "\r\n\r\n" . ltrim($message);
return $sent;
}
+ public function parse_mime($message, $decode_body = false)
+ {
+ list($headers, $message) = preg_split('/\r?\n\r?\n/', $message, 2, PREG_SPLIT_NO_EMPTY);
+
+ // Parse headers to get sender and recipients
+ $headers = str_replace("\r\n", "\n", $headers);
+ $headers = explode("\n", trim($headers));
+
+ $ln = 0;
+ $lines = array();
+
+ foreach ($headers as $line) {
+ if (ord($line[0]) <= 32) {
+ $lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($line);
+ }
+ else {
+ $lines[++$ln] = trim($line);
+ }
+ }
+
+ $headers = array();
+ $headers_map = array(
+ 'subject' => 'Subject',
+ 'from' => 'From',
+ 'to' => 'To',
+ 'cc' => 'Cc',
+ 'bcc' => 'Bcc',
+ 'content-transfer-encoding' => 'Content-Transfer-Encoding',
+ );
+
+ foreach ($lines as $line) {
+ list($field, $string) = explode(':', $line, 2);
+ $_field = strtolower($field);
+
+ if (isset($headers_map[$_field])) {
+ $field = $headers_map[$_field];
+ }
+
+ $headers[$field] = trim($string);
+ }
+
+ // Decode body
+ if ($decode_body) {
+ $message = str_replace("\r\n", "\n", $message);
+ $encoding = strtolower($headers['Content-Transfer-Encoding']);
+
+ switch ($encoding) {
+ case 'base64':
+ $message = base64_decode($message);
+ break;
+ case 'quoted-printable':
+ $message = quoted_printable_decode($message);
+ break;
+ }
+ }
+
+ return array($headers, $message);
+ }
+
+ /**
+ * Creates complete MIME message body
+ *
+ * @param array $headers Message headers
+ * @param string $body Message body
+ *
+ * @return string Message source
+ */
+ public function build_mime($headers, $body)
+ {
+ // Encode the body
+ $encoding = strtolower($headers['Content-Transfer-Encoding']);
+
+ switch ($encoding) {
+ case 'base64':
+ $body = base64_encode($body);
+ $body = chunk_split($body, 76, "\r\n");
+ break;
+ case 'quoted-printable':
+ $body = quoted_printable_encode($body);
+ break;
+ }
+
+ foreach ($headers as $header => $header_value) {
+ $headers[$header] = $header . ': ' . $header_value;
+ }
+
+ // Build the complete message
+ return trim(implode("\r\n", $headers)) . "\r\n\r\n" . ltrim($body);
+ }
+
/**
* Unique Message-ID generator.
*
* @return string Message-ID
*/
/*
public function gen_message_id()
{
$local_part = md5(uniqid('rcmail'.mt_rand(),true));
// $domain_part = $this->user->get_username('domain');
// Try to find FQDN, some spamfilters doesn't like 'localhost' (#1486924)
if (!preg_match('/\.[a-z]+$/i', $domain_part)) {
foreach (array($_SERVER['HTTP_HOST'], $_SERVER['SERVER_NAME']) as $host) {
$host = preg_replace('/:[0-9]+$/', '', $host);
if ($host && preg_match('/\.[a-z]+$/i', $host)) {
$domain_part = $host;
}
}
}
return sprintf('<%s@%s>', $local_part, $domain_part);
}
*/
}
diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php
index 6e033ae..793feaf 100644
--- a/lib/kolab_sync_data.php
+++ b/lib/kolab_sync_data.php
@@ -1,1267 +1,1282 @@
<?php
/**
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
abstract class kolab_sync_data implements Syncroton_Data_IData
{
/**
* ActiveSync protocol version
*
* @var int
*/
protected $asversion = 0;
/**
* information about the current device
*
* @var Syncroton_Model_IDevice
*/
protected $device;
/**
* timestamp to use for all sync requests
*
* @var DateTime
*/
protected $syncTimeStamp;
/**
* name of model to use
*
* @var string
*/
protected $modelName;
/**
* type of the default folder
*
* @var int
*/
protected $defaultFolderType;
/**
* default container for new entries
*
* @var string
*/
protected $defaultFolder;
/**
* type of user created folders
*
* @var int
*/
protected $folderType;
/**
* Default namespace
*
* @var string
*/
protected $defaultNS = 'Calendar';
/**
* field to sort search results by
*
* @var string
*/
protected $sortField;
/**
* Internal cache for kolab_storage folder objects
*
* @var array
*/
protected $folders = array();
/**
* Timezone
*
* @var string
*/
protected $timezone;
const RESULT_OBJECT = 0;
const RESULT_UID = 1;
const RESULT_COUNT = 2;
/**
* Recurrence types
*/
const RECUR_TYPE_DAILY = 0; // Recurs daily.
const RECUR_TYPE_WEEKLY = 1; // Recurs weekly
const RECUR_TYPE_MONTHLY = 2; // Recurs monthly
const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day
const RECUR_TYPE_YEARLY = 5; // Recurs yearly
const RECUR_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;
const RECUR_DOW_LAST = 127; // The last day of the month. Used as a special value in monthly or yearly recurrences.
/**
* Mapping of recurrence types
*
* @var array
*/
protected $recurTypeMap = array(
'DAILY' => self::RECUR_TYPE_DAILY,
'WEEKLY' => self::RECUR_TYPE_WEEKLY,
'MONTHLY' => self::RECUR_TYPE_MONTHLY,
'MONTHLY' => self::RECUR_TYPE_MONTHLY_DAYN,
'YEARLY' => self::RECUR_TYPE_YEARLY,
'YEARLY' => self::RECUR_TYPE_YEARLY_DAYN,
);
/**
* Mapping of weekdays
* NOTE: ActiveSync uses a bitmask
*
* @var array
*/
protected $recurDayMap = array(
'SU' => self::RECUR_DOW_SUNDAY,
'MO' => self::RECUR_DOW_MONDAY,
'TU' => self::RECUR_DOW_TUESDAY,
'WE' => self::RECUR_DOW_WEDNESDAY,
'TH' => self::RECUR_DOW_THURSDAY,
'FR' => self::RECUR_DOW_FRIDAY,
'SA' => self::RECUR_DOW_SATURDAY,
);
/**
* the constructor
*
* @param Syncroton_Model_IDevice $device
* @param DateTime $syncTimeStamp
*/
public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp)
{
$this->backend = kolab_sync_backend::get_instance();
$this->device = $device;
$this->asversion = floatval($device->acsversion);
$this->syncTimeStamp = $syncTimeStamp;
$this->defaultRootFolder = $this->defaultFolder . '::Syncroton';
// set internal timezone of kolab_format to user timezone
try {
$this->timezone = rcube::get_instance()->config->get('timezone', 'GMT');
kolab_format::$timezone = new DateTimeZone($this->timezone);
}
catch (Exception $e) {
//rcube::raise_error($e, true);
$this->timezone = 'GMT';
kolab_format::$timezone = new DateTimeZone('GMT');
}
}
/**
* return list of supported folders for this backend
*
* @return array
*/
public function getAllFolders()
{
$list = array();
// device supports multiple folders ?
if (in_array(strtolower($this->device->devicetype), array('iphone', 'ipad', 'thundertine', 'windowsphone'))) {
// get the folders the user has access to
$list = $this->backend->folders_list($this->device->deviceid, $this->modelName);
}
if ($default = $this->getDefaultFolder()) {
$list = array($default['folderid'] => $default);
}
foreach ($list as $idx => $folder) {
$list[$idx] = new Syncroton_Model_Folder($folder);
}
return $list;
}
/**
* Returns default folder for current class type.
*/
protected function getDefaultFolder()
{
// Check if there's any folder configured for sync
$folders = $this->backend->folders_list($this->device->deviceid, $this->modelName);
if (empty($folders)) {
return null;
}
foreach ($folders as $folder) {
if ($folder['type'] == $this->defaultFolderType) {
$default = $folder;
break;
}
}
// Return first on the list if there's no default
if (empty($default)) {
$default = $folders[key($folders)];
}
// Remember real folder ID and set ID/name to root folder
$default['realid'] = $default['folderid'];
$default['folderid'] = $this->defaultRootFolder;
$default['displayname'] = $this->defaultFolder;
return $default;
}
/**
* Creates a folder
*/
public function createFolder(Syncroton_Model_IFolder $folder)
{
$parentid = $folder->parentid;
$type = $folder->type;
$display_name = $folder->displayname;
if ($parentid) {
$parent = $this->backend->folder_id2name($parentid, $this->device->deviceid);
}
$name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP');
if ($parent !== null) {
$rcube = rcube::get_instance();
$storage = $rcube->get_storage();
$delim = $storage->get_hierarchy_delimiter();
$name = $parent . $delim . $name;
}
// Create IMAP folder
$result = $this->backend->folder_create($name, $type, $this->device->deviceid);
if ($result) {
$folder->folderid = $this->backend->folder_id($name);
return $folder;
}
// @TODO: throw exception
}
/**
* Updates a folder
*/
public function updateFolder(Syncroton_Model_IFolder $folder)
{
$parentid = $folder->parentid;
$type = $folder->type;
$display_name = $folder->displayname;
$old_name = $this->backend->folder_id2name($folder->folderid, $this->device->deviceid);
if ($parentid) {
$parent = $this->backend->folder_id2name($parentid, $this->device->deviceid);
}
$name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP');
if ($parent !== null) {
$rcube = rcube::get_instance();
$storage = $rcube->get_storage();
$delim = $storage->get_hierarchy_delimiter();
$name = $parent . $delim . $name;
}
// Rename/move IMAP folder
if ($name == $old_name) {
$result = true;
// @TODO: folder type change?
}
else {
$result = $this->backend->folder_rename($old_name, $name, $type, $this->device->deviceid);
}
if ($result) {
$folder->folderid = $this->backend->folder_id($name);
return $folder;
}
// @TODO: throw exception
}
/**
* Deletes a folder
*/
public function deleteFolder($folder)
{
if ($folder instanceof Syncroton_Model_IFolder) {
$folder = $folder->folderid;
}
$name = $this->backend->folder_id2name($folder, $this->device->deviceid);
// @TODO: throw exception
return $this->backend->folder_delete($name, $this->device->deviceid);
}
public function moveItem($srcFolderId, $serverId, $dstFolderId)
{
$item = $this->getObject($srcFolderId, $serverId, $folder);
if ($item && $folder) {
$dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid);
if ($dstname === null) {
return;
}
if (!$folder->move($serverId, $dstname)) {
return;
}
}
return $item['uid'];
}
/**
- * add entry from xml data
+ * Add entry
*
- * @param string $folderId
- * @param Syncroton_Model_IEntry $entry
+ * @param string $folderId Folder identifier
+ * @param Syncroton_Model_IEntry $entry Entry object
*
- * @return array
+ * @return string ID of the created entry
*/
public function createEntry($folderId, Syncroton_Model_IEntry $entry)
{
$entry = $this->toKolab($entry, $folderId);
$entry = $this->createObject($folderId, $entry);
+ // @TODO: throw exception on error
return $entry['uid'];
}
/**
* update existing entry
*
* @param string $folderId
* @param string $serverId
* @param SimpleXMLElement $entry
- * @return array
+ *
+ * @return string ID of the updated entry
*/
public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry)
{
+/* @TODO:
+ Certain in-schema properties remain untouched in the following three cases:
+
+ If there is only an email:Flag ([MS-ASEMAIL] section 2.2.2.27), email:Read ([MS-ASEMAIL] section 2.2.2.47), or email:Categories ([MS-ASEMAIL] section 2.2.2.9) change (that is, if only an email:Flag, email:Categories or email:Read element is present), all other properties will remain unchanged and the client SHOULD NOT send the other elements in the request. If all the other elements are sent, extra bandwidth is used, but no errors occur.
+
+ If a calendar:Exceptions ([MS-ASCAL] section 2.2.2.20) node is not specified, the properties for that calendar:Exceptions node will remain unchanged. If a calendar:Exception ([MS-ASCAL] section 2.2.2.19) node within the calendar:Exceptions node is not present, that particular exception will remain unchanged.
+
+ If the airsyncbase:Body, airsyncbase:Data, or contacts:Picture elements are not present, the corresponding properties will remain unchanged.
+
+ In all other cases, if an in-schema property is not specified in a change request, the property is actively deleted from the item on the server. A client MUST be aware of this when it is sending Sync requests; otherwise, data can be unintentionally removed.
+*/
$oldEntry = $this->getObject($folderId, $serverId);
if (empty($oldEntry)) {
throw new Syncroton_Exception_NotFound('id not found');
}
$entry = $this->toKolab($entry, $folderId, $oldEntry);
$entry = $this->updateObject($folderId, $serverId, $entry);
+ // @TODO: throw exception on error
return $entry['uid'];
}
/**
* delete entry
*
* @param string $folderId
* @param string $serverId
* @param array $collectionData
*/
public function deleteEntry($folderId, $serverId, $collectionData)
{
$this->deleteObject($folderId, $serverId);
}
public function getFileReference($fileReference)
{
-
+ // to be implemented by Email data class
+ // @TODO: throw "unimplemented" exception here?
}
/**
* 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
*/
public function search($folderid, $filter = array(), $result_type = self::RESULT_UID)
{
if ($folderid == $this->defaultRootFolder) {
$folders = $this->backend->folders_list($this->device->deviceid, $this->modelName);
$folders = array_keys($folders);
}
else {
$folders = array($folderid);
}
// there's a PHP Warning from kolab_storage if $filter isn't an array
if (empty($filter)) {
$filter = array();
}
$result = $result_type == self::RESULT_COUNT ? 0 : array();
foreach ($folders as $folderid) {
$foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid);
if ($foldername === null) {
continue;
}
$folder = $this->getFolderObject($foldername);
if (!$folder) {
continue;
}
switch ($result_type) {
case self::RESULT_COUNT:
$result += (int) $folder->count($filter);
break;
case self::RESULT_UID:
if ($uids = $folder->get_uids($filter)) {
$result = array_merge($result, $uids);
}
break;
case self::RESULT_OBJECT:
default:
if ($objects = $folder->select($filter)) {
$result = array_merge($result, $objects);
}
}
}
return $result;
}
/**
* 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)
{
/*
From Syncroton_Command_Sync:
const FILTER_NOTHING = 0;
const FILTER_1_DAY_BACK = 1;
const FILTER_3_DAYS_BACK = 2;
const FILTER_1_WEEK_BACK = 3;
const FILTER_2_WEEKS_BACK = 4;
const FILTER_1_MONTH_BACK = 5;
const FILTER_3_MONTHS_BACK = 6;
const FILTER_6_MONTHS_BACK = 7;
const FILTER_INCOMPLETE = 8;
Doc: MS-ASCMD 2.2.3.64.2 FilterType (Sync)
*/
// overwrite by child class according to specified type
return array();
}
/**
* get all entries changed between to dates
*
* @param string $folderId
* @param DateTime $start
* @param DateTime $end
* @return array
*/
public function getChangedEntries($folderId, DateTime $start, DateTime $end = null)
{
$filter = array();
$filter[] = array('changed', '>', $start);
if ($endTimeStamp) {
$filter[] = array('changed', '<=', $end);
}
$result = $this->search($folderId, $filter, self::RESULT_UID);
return $result;
}
/**
* get count of entries changed between two dates
*
* @param string $folderId
* @param DateTime $start
* @param DateTime $end
*
* @return int
*/
public function getChangedEntriesCount($folderId, DateTime $start, DateTime $end = null)
{
$filter = array();
$filter[] = array('changed', '>', $start);
if ($endTimeStamp) {
$filter[] = array('changed', '<=', $end);
}
$result = $this->search($folderId, $filter, self::RESULT_COUNT);
return $result;
}
/**
* get id's of all entries available on the server
*
* @param string $folderId
* @param int $filterType
*
* @return array
*/
public function getServerEntries($folder_id, $filter_type)
{
// @TODO: sorting? 'sort' => $this->sortField
$filter = $this->filter($filter_type);
$result = $this->search($folder_id, $filter, self::RESULT_UID);
return $result;
}
/**
* get count of all entries available on the server
*
* @param string $folderId
* @param int $filterType
*
* @return int
*/
public function getServerEntriesCount($folder_id, $filter_type)
{
$filter = $this->filter($filter_type);
$result = $this->search($folder_id, $filter, self::RESULT_COUNT);
return $result;
}
public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState)
{
$allClientEntries = $contentBackend->getFolderState($this->device, $folder);
// @TODO: Consider looping over all folders here, not in getServerEntries() and
// getChangesEntriesCount(). This way we could break the loop and not check all folders (see @TODO below)
// or at least skip redundant cache sync of the same folder
$allServerEntries = $this->getServerEntries($folder->folderid, $folder->lastfiltertype);
$addedEntries = array_diff($allServerEntries, $allClientEntries);
$deletedEntries = array_diff($allClientEntries, $allServerEntries);
// @TODO: Count is needed only for GetItemEstimate command
// in Ping command we need only information that anything is changed/added/deleted
// so in case when count($addedEntries) + count($deletedEntries) != 0 we don't need
// to count changed entries here. We could also revert the order in such case
// and execute getChangedEntriesCount() before
$changedEntries = $this->getChangedEntriesCount($folder->folderid, $syncState->lastsync);
return count($addedEntries) + count($deletedEntries) + $changedEntries;
}
/**
* Fetches the entry from the backend
*/
protected function getObject($folderid, $entryid, &$folder = null)
{
if ($folderid instanceof Syncroton_Model_IFolder) {
$folderid = $folderid->folderid;
}
if ($folderid == $this->defaultRootFolder) {
$folders = $this->backend->folders_list($this->device->deviceid, $this->modelName);
$folders = array_keys($folders);
}
else {
$folders = array($folderid);
}
foreach ($folders as $folderid) {
$foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid);
if ($foldername === null) {
continue;
}
$folder = $this->getFolderObject($foldername);
if ($object = $folder->get_object($entryid)) {
$object['_folderid'] = $folderid;
return $object;
}
}
}
/**
* Saves the entry on the backend
*/
protected function createObject($folderid, $data)
{
if ($folderid == $this->defaultRootFolder) {
$default = $this->getDefaultFolder();
$folderid = isset($default['realid']) ? $default['realid'] : $default['folderid'];
}
$foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid);
$folder = $this->getFolderObject($foldername);
if ($folder->save($data)) {
return $data;
}
}
/**
* Updates the entry on the backend
*/
protected function updateObject($folderid, $entryid, $data)
{
$object = $this->getObject($folderid, $entryid);
if ($object) {
$folder = $this->getFolderObject($object['_mailbox']);
if ($folder->save($data)) {
return $data;
}
}
}
/**
* Removes the entry from the backend
*/
protected function deleteObject($folderid, $entryid)
{
$object = $this->getObject($folderid, $entryid);
if ($object) {
$folder = $this->getFolderObject($object['_mailbox']);
return $folder->delete($entryid);
}
}
/**
* Returns Folder object (uses internal cache)
*
* @param string $name Folder name (UTF7-IMAP)
*
* @return kolab_storage_folder Folder object
*/
protected function getFolderObject($name)
{
if (!isset($this->folders[$name])) {
$this->folders[$name] = kolab_storage::get_folder($name);
}
return $this->folders[$name];
}
/**
* Returns ActiveSync settings of specified folder
*
* @param string $name Folder name (UTF7-IMAP)
*
* @return array Folder settings
*/
protected function getFolderConfig($name)
{
$metadata = $this->backend->folder_meta();
$deviceid = $this->device->deviceid;
$config = $metadata[$name]['FOLDER'][$deviceid];
return array(
'ALARMS' => $config['S'] == 2,
);
}
/**
* Convert contact from xml to kolab format
*
* @param Syncroton_Model_IEntry $data Contact data
* @param string $folderId Folder identifier
* @param array $entry Old Contact data for merge
*
* @return array
*/
abstract public function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null);
/**
* Removes control chars from string which are not allowed in ActiveSync
*
* @param string $value Text
*
* @return string Text
* @deprecated
*/
public static function quote($value)
{
if ($value && !ctype_print($value)) {
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', null, $value);
}
return $value;
}
/**
* Extracts data from kolab data array
*/
protected function getKolabDataItem($data, $name)
{
$name_items = explode('.', $name);
$count = count($name_items);
// multi-level array (e.g. address, phone)
if ($count == 3) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!empty($data[$name]) && is_array($data[$name])) {
foreach ($data[$name] as $element) {
if ($element['type'] == $type) {
return $element[$key_name];
}
}
}
return null;
}
/*
// hash array e.g. organizer
else if ($count == 2) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!empty($data[$name]) && is_array($data[$name])) {
foreach ($data[$name] as $element) {
if ($element['type'] == $type) {
return $element[$key_name];
}
}
}
return null;
}
*/
$name_items = explode(':', $name);
$name = $name_items[0];
if (empty($data[$name])) {
return null;
}
// simple array (e.g. email)
if (count($name_items) == 2) {
return $data[$name][$name_items[1]];
}
return $data[$name];
}
/**
* Saves data in kolab data array
*/
protected function setKolabDataItem(&$data, $name, $value)
{
if (empty($value)) {
return $this->unsetKolabDataItem($data, $name);
}
$name_items = explode('.', $name);
// multi-level array (e.g. address, phone)
if (count($name_items) == 3) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!isset($data[$name])) {
$data[$name] = array();
}
foreach ($data[$name] as $idx => $element) {
if ($element['type'] == $type) {
$found = $idx;
break;
}
}
if (!isset($found)) {
$data[$name] = array_values($data[$name]);
$found = count($data[$name]);
$data[$name][$found] = array('type' => $type);
}
$data[$name][$found][$key_name] = $value;
return;
}
$name_items = explode(':', $name);
$name = $name_items[0];
// simple array (e.g. email)
if (count($name_items) == 2) {
$data[$name][$name_items[1]] = $value;
return;
}
$data[$name] = $value;
}
/**
* Unsets data item in kolab data array
*/
protected function unsetKolabDataItem(&$data, $name)
{
$name_items = explode('.', $name);
// multi-level array (e.g. address, phone)
if (count($name_items) == 3) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!isset($data[$name])) {
return;
}
foreach ($data[$name] as $idx => $element) {
if ($element['type'] == $type) {
$found = $idx;
break;
}
}
if (!isset($found)) {
return;
}
unset($data[$name][$found][$key_name]);
// if there's only one element and it's 'type', remove it
if (count($data[$name][$found]) == 1 && isset($data[$name][$found]['type'])) {
unset($data[$name][$found]['type']);
}
if (empty($data[$name][$found])) {
unset($data[$name][$found]);
}
if (empty($data[$name])) {
unset($data[$name]);
}
return;
}
$name_items = explode(':', $name);
$name = $name_items[0];
// simple array (e.g. email)
if (count($name_items) == 2) {
unset($data[$name][$name_items[1]]);
if (empty($data[$name])) {
unset($data[$name]);
}
return;
}
unset($data[$name]);
}
/**
* Setter for Body attribute according to client version
*
* @param string $value Body
* @param array $param Body parameters
*
* @reurn Syncroton_Model_EmailBody Body element
*/
protected function setBody($value, $params = array())
{
if (empty($value)) {
return;
}
$params['Data'] = $value;
if (!isset($params['Type'])) {
$params['Type'] = 1;
}
return new Syncroton_Model_EmailBody($params);
/*
// strip off any non printable control characters
$value = self::quote($value);
if ($this->asversion >= 12) {
$body = $domParent->appendChild(new DOMElement('Body', null, 'uri:AirSyncBase'));
$body->appendChild(new DOMElement('Type', 1, 'uri:AirSyncBase'));
$dataTag = new DOMElement('Data', null, 'uri:AirSyncBase');
// ... append it to parent node aka append it to the document ...
$body->appendChild($dataTag);
// ... and now add the content (DomText takes care of special chars)
$dataTag->appendChild(new DOMText($value));
} else {
// create a new DOMElement ...
$node = new DOMElement('Body', null, 'uri:' . $this->defaultNS);
// ... append it to parent node aka append it to the document ...
$domParent->appendChild($node);
// ... and now add the content (DomText takes care of special chars)
$node->appendChild(new DOMText($value));
}
*/
}
/**
* Getter for Body attribute value according to client version
*
* @param mixed $data Data element
*/
protected function getBody($data)
{
if ($data && $data->Data) {
return $data->Data;
}
/*
if ($this->asversion >= 12) {
$airSyncBase = $data->children('uri:AirSyncBase');
if (isset($airSyncBase->Body)) {
return (string)$airSyncBase->Body->Data;
}
}
else if (isset($classData->Body)) {
return (string) $classData->Body;
}
*/
}
/**
* Converts PHP DateTime, date (YYYY-MM-DD) or unixtimestamp into PHP DateTime in UTC
*
* @param DateTime|int|string $date Unix timestamp, date (YYYY-MM-DD) or PHP DateTime object
*
* @return DateTime Datetime object
*/
protected static function date_from_kolab($date)
{
if (!empty($date)) {
if (is_numeric($date)) {
$date = new DateTime('@' . $date);
}
else if (is_string($date)) {
$date = new DateTime($date, new DateTimeZone('UTC'));
}
else {
$date = clone $date;
$tz = $date->getTimezone();
$tz_name = $tz->getName();
// convert to UTC if needed
if ($tz_name != 'UTC') {
$date->setTimezone(new DateTimeZone('UTC'));
}
}
return $date;
}
}
/**
* Convert Kolab event/task recurrence into ActiveSync
*/
protected function recurrence_from_kolab($data, $type = 'Event')
{
if (empty($data['recurrence'])) {
return null;
}
$recurrence = array();
$r = $data['recurrence'];
// required fields
switch($r['FREQ']) {
case 'DAILY':
$recurrence['Type'] = self::RECUR_TYPE_DAILY;
break;
case 'WEEKLY':
$recurrence['Type'] = self::RECUR_TYPE_WEEKLY;
$recurrence['DayOfWeek'] = $this->day2bitmask($r['BYDAY']);
break;
case 'MONTHLY':
if (!empty($r['BYMONTHDAY'])) {
// @TODO: ActiveSync doesn't support multi-valued month days,
// should we replicate the recurrence element for each day of month?
$month_day = array_shift(explode(',', $r['BYMONTHDAY']));
$recurrence['Type'] = self::RECUR_TYPE_MONTHLY;
$recurrence['DayOfMonth'] = $month_day;
}
else {
$week = (int) substr($r['BYDAY'], 0, -2);
$week = ($week == -1) ? 5 : $week;
$day = substr($r['BYDAY'], -2);
$recurrence['Type'] = self::RECUR_TYPE_MONTHLY_DAYN;
$recurrence['WeekOfMonth'] = $week;
$recurrence['DayOfWeek'] = $this->day2bitmask($day);
}
break;
case 'YEARLY':
// @TODO: ActiveSync doesn't support multi-valued months,
// should we replicate the recurrence element for each month?
$month = array_shift(explode(',', $r['BYMONTH']));
if (!empty($r['BYDAY'])) {
$week = (int) substr($r['BYDAY'], 0, -2);
$week = ($week == -1) ? 5 : $week;
$day = substr($r['BYDAY'], -2);
$recurrence['Type'] = self::RECUR_TYPE_YEARLY_DAYN;
$recurrence['WeekOfMonth'] = $week;
$recurrence['DayOfWeek'] = $this->day2bitmask($day);
$recurrence['MonthOfYear'] = $month;
}
else if (!empty($r['BYMONTHDAY'])) {
// @TODO: ActiveSync doesn't support multi-valued month days,
// should we replicate the recurrence element for each day of month?
$month_day = array_shift(explode(',', $r['BYMONTHDAY']));
$recurrence['Type'] = self::RECUR_TYPE_YEARLY;
$recurrence['DayOfMonth'] = $month_day;
$recurrence['MonthOfYear'] = $month;
}
else {
$recurrence['Type'] = self::RECUR_TYPE_YEARLY;
$recurrence['MonthOfYear'] = $month;
}
break;
}
// required field
$recurrence['Interval'] = $r['INTERVAL'] ? $r['INTERVAL'] : 1;
if (!empty($r['UNTIL'])) {
$recurrence['Until'] = $this->date_from_kolab($r['UNTIL']);
}
else if (!empty($r['COUNT'])) {
$recurrence['Occurrences'] = $r['COUNT'];
}
$class = 'Syncroton_Model_' . $type . 'Recurrence';
return new $class($recurrence);
}
/**
* Convert ActiveSync event/task recurrence into Kolab
*/
protected function recurrence_to_kolab($data, $timezone = null)
{
if (!isset($data->Recurrence) || isset($data->Recurrence->Type)) {
return null;
}
$type = $data->Recurrence->Type;
$rrule['FREQ'] = array_search($type, $this->recurTypeMap);
switch ($type) {
case self::RECUR_TYPE_DAILY:
break;
case self::RECUR_TYPE_WEEKLY:
$rrule['BYDAY'] = $this->bitmask2day($data->Recurrence->DayOfWeek);
break;
case self::RECUR_TYPE_MONTHLY:
$rrule['BYMONTHDAY'] = $data->Recurrence->DayOfMonth;
break;
case self::RECUR_TYPE_MONTHLY_DAYN:
$week = $data->Recurrence->WeekOfMonth;
$day = $data->Recurrence->DayOfWeek;
$byDay = $week == 5 ? -1 : $week;
$byDay .= $this->bitmask2day($day);
$rrule['BYDAY'] = $byDay;
break;
case self::RECUR_TYPE_YEARLY:
$rrule['BYMONTH'] = $data->Recurrence->MonthOfYear;
$rrule['BYMONTHDAY'] = $data->Recurrence->DayOfMonth;
break;
case self::RECUR_TYPE_YEARLY_DAYN:
$rrule['BYMONTH'] = $data->Recurrence->MonthOfYear;
$week = $data->Recurrence->WeekOfMonth;
$day = $data->Recurrence->DayOfWeek;
$byDay = $week == 5 ? -1 : $week;
$byDay .= $this->bitmask2day($day);
$rrule['BYDAY'] = $byDay;
break;
}
$rrule['INTERVAL'] = isset($data->Recurrence->Interval) ? $data->Recurrence->Interval : 1;
if (isset($data->Recurrence->Until)) {
if ($timezone) {
$data->Recurrence->Until->setTimezone($timezone);
}
$rrule['UNTIL'] = $data->Recurrence->Until;
}
else if (!empty($data->Recurrence->Occurrences)) {
$rrule['COUNT'] = $data->Recurrence->Occurrences;
}
return $rrule;
}
/**
* Convert Kolab event recurrence exceptions into ActiveSync
*/
protected function exceptions_from_kolab($data)
{
/*
// handle exceptions of repeating events
if($data->exdate && $data->exdate->count() > 0) {
$exceptionsTag = $_domParent->appendChild(new DOMElement('Exceptions', null, 'uri:Calendar'));
foreach ($data->exdate as $exception) {
$exceptionTag = $exceptionsTag->appendChild(new DOMElement('Exception', null, 'uri:Calendar'));
$exceptionTag->appendChild(new DOMElement('Deleted', (int)$exception->is_deleted, 'uri:Calendar'));
$exceptionTag->appendChild(new DOMElement('ExceptionStartTime', $exception->getOriginalDtStart()->format('Ymd\THis') . 'Z', 'uri:Calendar'));
if ((int)$exception->is_deleted === 0) {
$this->appendXML($exceptionTag, $_collectionData, $exception);
}
}
}
*/
}
/**
* Convert ActiveSync event recurrence exceptions into Kolab
*/
protected function exceptions_to_kolab($data, $timezone = null)
{
/*
// handle exceptions from recurrence
if (isset($xmlData->Exceptions)) {
$exdates = new Tinebase_Record_RecordSet('Calendar_Model_Event');
foreach ($xmlData->Exceptions->Exception as $exception) {
$eventException = new Calendar_Model_Event(array(
'recurid' => new Tinebase_DateTime((string)$exception->ExceptionStartTime)
));
if ((int)$exception->Deleted === 0) {
$eventException->is_deleted = false;
$this->toTineModel($exception, $eventException);
} else {
$eventException->is_deleted = true;
}
$exdates->addRecord($eventException);
}
$event->exdate = $exdates;
}
*/
}
/**
* Converts string of days (TU,TH) to bitmask used by ActiveSync
*
* @param string $days
*
* @return int
*/
protected function day2bitmask($days)
{
$days = explode(',', $days);
$result = 0;
foreach ($days as $day) {
$result = $result + $this->recurDayMap[$day];
}
return $result;
}
/**
* Convert bitmask used by ActiveSync to string of days (TU,TH)
*
* @param int $days
*
* @return string
*/
protected function bitmask2day($days)
{
$days_arr = array();
for ($bitmask = 1; $bitmask <= self::RECUR_DOW_SATURDAY; $bitmask = $bitmask << 1) {
$dayMatch = $days & $bitmask;
if ($dayMatch === $bitmask) {
$days_arr[] = array_search($bitmask, $this->recurDayMap);
}
}
$result = implode(',', $days_arr);
return $result;
}
}
diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php
index 2cc1ba1..df457b2 100644
--- a/lib/kolab_sync_data_email.php
+++ b/lib/kolab_sync_data_email.php
@@ -1,916 +1,1032 @@
<?php
/**
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
*
*/
class kolab_sync_data_email extends kolab_sync_data
{
/**
* Mapping from ActiveSync Email namespace fields
*/
protected $mapping = array(
'Cc' => 'cc',
//'ContentClass' => 'contentclass',
'DateReceived' => 'internaldate',
//'DisplayTo' => 'displayto', //?
//'Flag' => 'flag',
//'FlagType' => 'flagtype', // child of Flag element
'From' => 'from',
//'Importance' => 'importance',
'InternetCPID' => 'charset',
//'MessageClass' => 'messageclass',
'ReplyTo' => 'replyto',
//'Read' => 'read',
'Subject' => 'subject',
//'ThreadTopic' => 'threadtopic',
'To' => 'to',
);
/**
* 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;
/**
* Default namespace
*
* @var string
*/
protected $defaultNS = 'Email';
/**
* Field to sort search results by
*
* @var string
*/
protected $sortField = 'n_fileas';
/**
* 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();
}
/**
* 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);
if (empty($message)) {
// @TODO: exception
return;
}
$msg = $this->parseMessageId($serverId);
$headers = $message->headers; // rcube_message_header
// Calendar namespace fields
foreach ($this->mapping as $key => $name) {
$value = null;
switch ($name) {
case 'internaldate':
$value = self::date_from_kolab(rcube_imap_generic::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) {
$name = $part['name'];
$mailto = $part['mailto'];
$string = $part['string'];
// @TODO: convert to utf8?
// @FIXME: set name + address or address only?
//rcube_utils::idn_to_utf8();
$addresses[$idx] = format_email_recipient($mailto, $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;
}
$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
/*
@TODO:
$result['Flag'] = array(
'FlagType' => 'FollowUp',
'Status' => 1,
);
*/
}
// Importance/Priority
if ($headers->priority) {
if ($headers->priority < 3) {
$result['importance'] = 2; // High
}
else if ($headers->priority > 3) {
$result['Importance'] = 0; // Low
}
}
// get truncation
$truncateAt = null;
$opts = $collection->options;
$prefs = $opts['bodyPreferences'];
if ($opts['mimeSupport'] == 2 && ($this->asversion <= 12 || !empty($prefs[4]))) {
if (!empty($prefs[4]) && !empty($prefs[4]['truncationSize'])) {
$truncateAt = $prefs[4]['truncationSize'];
}
$airSyncBaseType = 4;
}
else if (!empty($prefs[2])) {
if (!empty($prefs[2]['truncationSize'])) {
$truncateAt = $prefs[2]['truncationSize'];
}
$airSyncBaseType = 2;
}
else {
if (!empty($prefs[1]) && !empty($prefs[1]['truncationSize'])) {
$truncateAt = $prefs[1]['truncationSize'];
}
$airSyncBaseType = 1;
}
if (isset($opts['mimeTruncation']) && $opts['mimeTruncation'] < 8) {
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;
}
}
// Message body
if ($airSyncBaseType == 4) {
$messageBody = $this->storage->get_raw_body($message->uid);
/*
if ($this->asversion < 12) {
// if the email contains non 7bit ascii characters we can't transfer
// them via MIMEData xml and we need to fall back to plain text
if (preg_match('/[^\x00-\x7F]/', $messageBody)) {
$airSyncBaseType = 1;
$messageBody = $this->getMessageBody($message);
}
}
*/
}
else {
$messageBody = $this->getMessageBody($message, $airSyncBaseType == 2);
}
// strip out any non utf-8 characters
$messageBody = rcube_charset::clean($messageBody);
$real_length = $body_length = strlen($messageBody);
if ($truncateAt !== null && $body_length > $truncateAt) {
$messageBody = substr($messageBody, 0, $truncateAt);
$body_length = $truncateAt;
$isTruncacted = 1;
}
else {
$isTruncacted = 0;
}
if ($body_length > 0) {
$body_params = array(
'Type' => $airSyncBaseType,
'Truncated' => $isTruncacted,
);
if ($isTruncated) {
$body_params['EstimatedDataSize'] = $real_length;
}
$result['Body'] = $this->setBody($messageBody, $body_params);
/*
if ($this->asversion >= 12) {
$body = $domParrent->appendChild(new DOMElement('Body', null, 'uri:AirSyncBase'));
$body->appendChild(new DOMElement('Type', $airSyncBaseType, 'uri:AirSyncBase'));
$body->appendChild(new DOMElement('Truncated', $isTruncacted, 'uri:AirSyncBase'));
$body->appendChild(new DOMElement('EstimatedDataSize', $data->size, 'uri:AirSyncBase'));
$dataTag = $body->appendChild(new DOMElement('Data', null, 'uri:AirSyncBase'));
$dataTag->appendChild(new DOMText($messageBody));
}
else {
if ($airSyncBaseType == 4) {
$domParrent->appendChild(new DOMElement('MIMETruncated', $isTruncacted, 'uri:Email'));
$body = $domParrent->appendChild(new DOMElement('MIMEData', null, 'uri:Email'));
$body->appendChild(new DOMText($messageBody));
}
else {
$domParrent->appendChild(new DOMElement('BodyTruncated', $isTruncacted, 'uri:Email'));
$body = $domParrent->appendChild(new DOMElement('Body', null, 'uri:Email'));
$body->appendChild(new DOMText($messageBody));
}
}
*/
}
$result['NativeBodyType'] = intval($message->has_html_part(false));
// Message class
$result['MessageClass'] = 'IPM.Note' . ($airSyncBaseType == 4 ? '.SMIME' : '');
$result['ContentClass'] = 'urn:content-classes:message';
// attachments
$attachments = array_merge($message->attachments, $message->inline_parts);
if (!empty($attachments)) {
$result['Attachments'] = array();
foreach ($attachments as $attachment) {
$att = array();
$filename = $attachment->filename;
if (empty($filename) && $attachment->mimetype == 'text/html') {
$filename = 'HTML Part';
}
$att['DisplayName'] = $filename;
$att['FileReference'] = $serverId . '::' . $attachment->mime_id;
$att['Method'] = 1;
$att['EstimatedDataSize'] = $attachment->size;
if (!empty($attachment->content_id)) {
$att['ContentId'] = $attachment->content_id;
}
if (!empty($attachment->content_location)) {
$att['ContentLocation'] = $attachment->content_location;
}
if (in_array($attachment, $message->inline_parts)) {
$att['IsInline'] = 1;
}
$result['Attachments'][] = new Syncroton_Model_EmailAttachment($att);
}
}
// @TODO: flags
return new Syncroton_Model_Email($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
*
* @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_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;
}
/**
* Returns default folder for current class type.
*/
protected function getDefaultFolder()
{
// There's always INBOX
$real_id = $this->backend->folder_id('INBOX', 'mail.inbox');
$folder_id = $this->defaultRootFolder;
return array(
'displayname' => 'Inbox',
'realid' => $real_id,
'folderid' => $folder_id,
'parentid' => 0,
'type' => $this->defaultFolderType,
);
}
public function moveItem($srcFolderId, $serverId, $dstFolderId)
{
$msg = $this->parseMessageId($serverId);
$dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid);
if ($dstname === null) {
return;
}
if (!$this->storage->move_message($msg['uid'], $dstname, $msg['foldername'])) {
return;
}
// 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 $uid;
}
}
/**
* add entry from xml data
*
* @param string $folderId
* @param Syncroton_Model_IEntry $entry
*
* @return array
*/
public function createEntry($folderId, Syncroton_Model_IEntry $entry)
{
}
/**
* update existing entry
*
* @param string $folderId
* @param string $serverId
* @param Syncroton_Model_IEntry $entry
* @return array
*/
public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry)
{
$msg = $this->parseMessageId($serverId);
$message = $this->getObject($serverId);
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']);
}
$is_flagged = !empty($message->headers->flags['FLAGGED']);
// @TODO: Flag change
if (empty($entry->Flag)) {
if ($is_flagged) {
$this->storage->set_flag($msg['uid'], 'UNFLAGGED', $msg['foldername']);
}
}
else if (!$is_flagged) {
/*
if ($entry->Flag->FlagType && preg_match('/^follow\s*up/i', $entry->Flag->FlagType)) {
$this->storage->set_flag($msg['uid'], 'FLAGGED', $msg['foldername']);
}
*/
}
}
/**
* 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);
// 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']);
}
}
public function sendEmail($body, $saveInSent)
{
$sent = $this->backend->send_message($body, $smtp_error);
if ($sent && $saveInSent) {
$this->saveInSent($body);
}
}
public function forwardEmail($collectionId, $itemId, $body, $saveInSent)
{
- // @TODO: not implemented
+ /*
+ @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).
+ */
+ /*
+ @TODO:
+ The LongId element is an optional child element of the Source element in SmartForward command requests and
+ SmartReply command requests that specifies the long ID for the source message, which is returned in the Search
+ command response message (section 2.2.2.14.2). If the LongId element is present, the FolderId (section 2.2.3.69),
+ ItemId (section 2.2.3.84), and InstanceId (section 2.2.3.83.2) elements are not present.
+ The LongId element value can be up to 256 characters in length.
+ */
+
+ $msg = $this->parseMessageId($itemId);
+ $message = $this->getObject($itemId);
+
+ // Parse message
+ list($headers, $body) = $this->backend->parse_mime($body, true);
+
+ // Get original message body
+ // @TODO: here we're assuming that reply message is in text/plain format
+ // So, original message will be converted to plain text if needed
+ // @TODO: what about forward-as-attachment?
+ $message_body = $this->getMessageBody($message, false);
+ $message_body = trim($message_body);
+
+ // Join bodies
+ $body = rtrim($body) . "\n\n" . ltrim($message_body);
+
+ // @TODO: add attachments from the original message
+
+ // Create complete message source
+ $body = $this->backend->build_mime($headers, $body);
+
+ // Send message
+ $sent = $this->backend->send_message($body, $smtp_error);
+
+ // Set FORWARDED flag on the replied message
+ if ($sent && empty($message->headers->flags['FORWARDED'])) {
+ $this->storage->set_flag($msg['uid'], 'FORWARDED', $msg['foldername']);
+ }
+ // Save sent message in Sent folder
if ($sent && $saveInSent) {
$this->saveInSent($body);
}
}
public function replyEmail($collectionId, $itemId, $body, $saveInSent)
{
- // @TODO: not implemented
+ $msg = $this->parseMessageId($itemId);
+ $message = $this->getObject($itemId);
+
+ // Parse message
+ // @TODO: messages with attachments
+ list($headers, $body) = $this->backend->parse_mime($body, true);
+
+ // Get original message body
+ // @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
+ $body = rtrim($body) . "\n" . ltrim($message_body);
+
+ // Create complete message source
+ $body = $this->backend->build_mime($headers, $body);
+ // Send message
+ $sent = $this->backend->send_message($body, $smtp_error);
+
+ // Set ANSWERED flag on the replied message
+ if ($sent && empty($message->headers->flags['ANSWERED'])) {
+ $this->storage->set_flag($msg['uid'], 'ANSWERED', $msg['foldername']);
+ }
+
+ // Save sent message in Sent folder
if ($sent && $saveInSent) {
$this->saveInSent($body);
}
}
/**
* Append the message into Sent folder
*
* @param string $message Complete message source
*
* @return int|bool Appended message UID or True on success, False on error
*/
protected function saveInSent($message)
{
$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);
}
return false;
}
/**
* 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
*/
public function search($folderid, $filter = array(), $result_type = self::RESULT_UID)
{
if ($folderid == $this->defaultRootFolder) {
$folders = $this->backend->folders_list($this->device->deviceid, $this->modelName);
$folders = array_keys($folders);
}
else {
$folders = array($folderid);
}
$filter_str = 'ALL UNDELETED';
// convert filter into one IMAP search string
foreach ($filter as $idx => $filter_item) {
if (is_array($filter_item)) {
// @TODO
// convert 'changed' entries into IMAP search string
// for now we just return empty result
return $result_type == self::RESULT_COUNT ? 0 : array();
}
else {
$filter_str .= ' ' . $filter_item;
}
}
$result = $result_type == self::RESULT_COUNT ? 0 : array();
// no sorting for best performance
$sort_by = null;
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);
$this->storage->search($foldername, $filter_str, RCMAIL_CHARSET, $sort_by);
$search = $this->storage->get_search_set();
$search = $search[1];
if (!($search instanceof rcube_result_index)) {
continue;
}
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($folderid, $uid);
}
$result = array_merge($result, $uids);
}
break;
/*
case self::RESULT_OBJECT:
default:
if ($objects = $folder->select($filter)) {
$result = array_merge($result, $objects);
}
*/
}
}
return $result;
}
/**
* Fetches the entry from the backend
*/
protected function getObject($entryid, &$folder = null)
{
$message = $this->parseMessageId($entryid);
if (empty($message)) {
// @TODO: exception?
return null;
}
// set current folder
$this->storage->set_folder($message['foldername']);
// get message
$message = new rcube_message($message['uid']);
return $message;
}
/**
* @return Syncroton_Model_FileReference
*/
public function getFileReference($fileReference)
{
list($folderid, $uid, $part_id) = explode('::', $fileReference);
$message = $this->getObject($fileReference);
$part = $message->mime_parts[$part_id];
$body = $message->get_part_content($part_id);
$content_type = $part->mimetype;
-/*
- <ItemOperations xmlns="uri:ItemOperations" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:AirSync="uri:AirSync" xmlns:ItemOperations="uri:ItemOp
- ions"><Status>1</Status><Response><Fetch><Status>1</Status><AirSyncBase:FileReference>38b950ebd62cd9a66929c89615d0fc04::3::2</AirSyncBase:F
- eference><Properties><AirSyncBase:ContentType xmlns="uri:AirSyncBase">text/x-diff</AirSyncBase:ContentType></Properties></Fetch></Response>
- emOperations>
- }
-*/
return new Syncroton_Model_FileReference(array(
'ContentType' => $content_type,
'Data' => $body,
));
}
/**
* Parses entry ID to get folder name and UID of the message
*/
protected function parseMessageId($entryid)
{
list($folderid, $uid) = explode('::', $entryid);
$foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid);
if ($foldername === null || $foldername === false) {
// @TODO exception?
return null;
}
return array(
'uid' => $uid,
'folderid' => $folderid,
'foldername' => $foldername,
);
}
/**
* Creates entry ID of the message
*/
public function createMessageId($folderid, $uid)
{
return $folderid . '::' . $uid;
}
/**
* Returns body of the message in specified format
*/
protected function getMessageBody($message, $html = false)
{
if (!is_array($message->parts) && empty($message->body)) {
return '';
}
if (!empty($message->parts)) {
foreach ($message->parts as $part) {
// skip no-content and attachment parts (#1488557)
if ($part->type != 'content' || !$part->size || $message->is_attachment($part)) {
continue;
}
return $this->getMessagePartBody($message, $part, $html);
}
}
return $this->getMessagePartBody($message, $message, $html);
}
/**
* Returns body of the message part in specified format
*/
protected function getMessagePartBody($message, $part, $html = false)
{
// Check if we have enough memory to handle the message in it
// @FIXME: we need up to 5x more memory than the body
if (!rcube_utils::mem_check($part->size * 5)) {
return '';
}
if (empty($part->ctype_parameters) || empty($part->ctype_parameters['charset'])) {
$part->ctype_parameters['charset'] = $message->headers->charset;
}
// fetch part if not available
if (!isset($part->body)) {
$part->body = $message->get_part_content($part->mime_id);
}
// message is cached but not exists, or other error
if ($part->body === false) {
return '';
}
$body = $part->body;
if ($html) {
if ($part->ctype_secondary == 'html') {
}
else {
$body = '<pre>' . $body . '</pre>';
}
}
else {
if ($part->ctype_secondary == 'html') {
$txt = new 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;
}
public static function charset_to_cp($charset)
{
// @TODO: ?????
// The body is converted to utf-8 in get_part_content(), 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;
+ }
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 18, 10:14 AM (6 h, 14 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
435677
Default Alt Text
(171 KB)

Event Timeline