diff --git a/addressbook/inc/class.addressbook_zpush.inc.php b/addressbook/inc/class.addressbook_zpush.inc.php new file mode 100644 index 0000000000..cee4a1894e --- /dev/null +++ b/addressbook/inc/class.addressbook_zpush.inc.php @@ -0,0 +1,905 @@ + + * @author Klaus Leithoff + * @author Philip Herbert + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @version $Id$ + */ + +/** + * Addressbook activesync plugin + */ +class addressbook_activesync implements activesync_plugin_write, activesync_plugin_search_gal +{ + /** + * @var BackendEGW + */ + private $backend; + + /** + * Instance of addressbook_bo + * + * @var addressbook_bo + */ + private $addressbook; + + /** + * Mapping of ActiveSync SyncContact attributes to EGroupware contact array-keys + * + * @var array + */ + static public $mapping = array( + //'anniversary' => '', + 'assistantname' => 'assistent', + 'assistnamephonenumber' => 'tel_assistent', + 'birthday' => 'bday', + 'body' => 'note', + //'bodysize' => '', + //'bodytruncated' => '', + 'business2phonenumber' => 'tel_other', + 'businesscity' => 'adr_one_locality', + 'businesscountry' => 'adr_one_countryname', + 'businesspostalcode' => 'adr_one_postalcode', + 'businessstate' => 'adr_one_region', + 'businessstreet' => 'adr_one_street', + 'businessfaxnumber' => 'tel_fax', + 'businessphonenumber' => 'tel_work', + 'carphonenumber' => 'tel_car', + 'categories' => 'cat_id', + //'children' => '', // collection of 'child' elements + 'companyname' => 'org_name', + 'department' => 'org_unit', + 'email1address' => 'email', + 'email2address' => 'email_home', + //'email3address' => '', + 'fileas' => 'n_fileas', + 'firstname' => 'n_given', + 'home2phonenumber' => 'tel_cell_private', + 'homecity' => 'adr_two_locality', + 'homecountry' => 'adr_two_countryname', + 'homepostalcode' => 'adr_two_postalcode', + 'homestate' => 'adr_two_region', + 'homestreet' => 'adr_two_street', + 'homefaxnumber' => 'tel_fax_home', + 'homephonenumber' => 'tel_home', + 'jobtitle' => 'title', // unfortunatly outlook only has title & jobtitle, while EGw has 'n_prefix', 'title' & 'role', + 'lastname' => 'n_family', + 'middlename' => 'n_middle', + 'mobilephonenumber' => 'tel_cell', + 'officelocation' => 'room', + //'othercity' => '', + //'othercountry' => '', + //'otherpostalcode' => '', + //'otherstate' => '', + //'otherstreet' => '', + 'pagernumber' => 'tel_pager', + //'radiophonenumber' => '', + //'spouse' => '', + 'suffix' => 'n_suffix', + 'title' => 'n_prefix', + 'webpage' => 'url', + //'yomicompanyname' => '', + //'yomifirstname' => '', + //'yomilastname' => '', + //'rtf' => '', + 'picture' => 'jpegphoto', + //'nickname' => '', + //'airsyncbasebody' => '', + ); + /** + * ID of private addressbook + * + * @var int + */ + const PRIVATE_AB = 0x7fffffff; + + /** + * Constructor + * + * @param BackendEGW $backend + */ + public function __construct(BackendEGW $backend) + { + $this->backend = $backend; + } + + /** + * Get addressbooks (no extra private one and do some caching) + * + * Takes addessbook-abs and addressbook-all-in-one preference into account. + * + * @param int $account =null account_id of addressbook or null to get array of all addressbooks + * @param boolean $return_all_in_one =true if false and all-in-one pref is set, return all selected abs + * if true only the all-in-one ab is returned (with id of personal ab) + * @param booelan $ab_prefix =false prefix personal, private and accounts addressbook with lang('Addressbook').' ' + * @return string|array addressbook name of array with int account_id => label pairs + */ + private function get_addressbooks($account=null,$return_all_in_one=true, $ab_prefix=false) + { + static $abs=null; + + if (!isset($abs) || !$return_all_in_one) + { + if ($return_all_in_one && $GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one']) + { + $abs = array( + $GLOBALS['egw_info']['user']['account_id'] => lang('All'), + ); + } + else + { + translation::add_app('addressbook'); // we need the addressbook translations + + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + // error_log(print_r($this->addressbook->get_addressbooks(EGW_ACL_READ),true)); + $pref = $GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-abs']; + $pref_abs = (string)$pref_abs !== '' ? explode(',',$pref) : array(); + + foreach ($this->addressbook->get_addressbooks() as $account_id => $label) + { + if ((string)$account_id == $GLOBALS['egw_info']['user']['account_id'].'p') + { + $account_id = self::PRIVATE_AB; + } + if ($account_id && in_array($account_id,$pref_abs) || in_array('A',$pref_abs) || + $account_id == 0 && in_array('U',$pref_abs) || + $account_id == $GLOBALS['egw_info']['user']['account_id'] || // allways sync pers. AB + $account_id == $GLOBALS['egw_info']['user']['account_primary_group'] && in_array('G',$pref_abs)) + { + $abs[$account_id] = $label; + } + } + } + } + $ret = is_null($account) ? $abs : + ($ab_prefix && (!$account || (int)$account == (int)$GLOBALS['egw_info']['user']['account_id']) ? + lang('Addressbook').' ' : '').$abs[$account]; + //error_log(__METHOD__."($account, $return_all_in_one, $ab_prefix) returning ".array2string($ret)); + return $ret; + } + + /** + * This function is analogous to GetMessageList. + * + * @ToDo implement preference, include own private calendar + */ + public function GetFolderList() + { + // error_log(print_r($this->addressbook->get_addressbooks(EGW_ACL_READ),true)); + $folderlist = array(); + foreach ($this->get_addressbooks() as $account => $label) + { + $folderlist[] = array( + 'id' => $this->backend->createID('addressbook',$account), + 'mod' => $label, + 'parent'=> '0', + ); + } + //debugLog(__METHOD__."() returning ".array2string($folderlist)); + //error_log(__METHOD__."() returning ".array2string($folderlist)); + return $folderlist; + } + + /** + * Get Information about a folder + * + * @param string $id + * @return SyncFolder|boolean false on error + */ + public function GetFolder($id) + { + $type = $owner = null; + $this->backend->splitID($id, $type, $owner); + + $folderObj = new SyncFolder(); + $folderObj->serverid = $id; + $folderObj->parentid = '0'; + $folderObj->displayname = $this->get_addressbooks($owner); + + if ($owner == $GLOBALS['egw_info']['user']['account_id']) + { + $folderObj->type = SYNC_FOLDER_TYPE_CONTACT; + } + else + { + $folderObj->type = SYNC_FOLDER_TYPE_USER_CONTACT; + } +/* + // not existing folder requested --> return false + if (is_null($folderObj->displayname)) + { + $folderObj = false; + debugLog(__METHOD__."($id) returning ".array2string($folderObj)); + } +*/ + //error_log(__METHOD__."('$id') returning ".array2string($folderObj)); + return $folderObj; + } + + /** + * Return folder stats. This means you must return an associative array with the + * following properties: + * + * "id" => The server ID that will be used to identify the folder. It must be unique, and not too long + * How long exactly is not known, but try keeping it under 20 chars or so. It must be a string. + * "parent" => The server ID of the parent of the folder. Same restrictions as 'id' apply. + * "mod" => This is the modification signature. It is any arbitrary string which is constant as long as + * the folder has not changed. In practice this means that 'mod' can be equal to the folder name + * as this is the only thing that ever changes in folders. (the type is normally constant) + * + * @return array with values for keys 'id', 'mod' and 'parent' + */ + public function StatFolder($id) + { + $type = $owner = null; + $this->backend->splitID($id, $type, $owner); + + $stat = array( + 'id' => $id, + 'mod' => $this->get_addressbooks($owner), + 'parent' => '0', + ); +/* + // not existing folder requested --> return false + if (is_null($stat['mod'])) + { + $stat = false; + debugLog(__METHOD__."('$id') ".function_backtrace()); + } +*/ + //error_log(__METHOD__."('$id') returning ".array2string($stat)); + debugLog(__METHOD__."('$id') returning ".array2string($stat)); + return $stat; + } + + /** + * Should return a list (array) of messages, each entry being an associative array + * with the same entries as StatMessage(). This function should return stable information; ie + * if nothing has changed, the items in the array must be exactly the same. The order of + * the items within the array is not important though. + * + * The cutoffdate is a date in the past, representing the date since which items should be shown. + * This cutoffdate is determined by the user's setting of getting 'Last 3 days' of e-mail, etc. If + * you ignore the cutoffdate, the user will not be able to select their own cutoffdate, but all + * will work OK apart from that. + * + * @todo if AB supports an extra private addressbook and AS prefs want an all-in-one AB, the private AB is always included, even if not selected in the prefs + * @param string $id folder id + * @param int $cutoffdate =null + * @return array + */ + function GetMessageList($id, $cutoffdate=NULL) + { + unset($cutoffdate); // not used, but required by function signature + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + $type = $user = null; + $this->backend->splitID($id,$type,$user); + $filter = array('owner' => $user); + + // handle all-in-one addressbook + if ($GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one'] && + $user == $GLOBALS['egw_info']['user']['account_id']) + { + $filter['owner'] = array_keys($this->get_addressbooks(null,false)); // false = return all selected abs + // translate AS private AB ID to EGroupware one + if (($key = array_search(self::PRIVATE_AB, $filter['owner'])) !== false) + { + $filter['owner'][$key] = $GLOBALS['egw_info']['user']['account_id'].'p'; + } + } + // handle private/personal addressbooks + elseif ($this->addressbook->private_addressbook && + ($user == self::PRIVATE_AB || $user == $GLOBALS['egw_info']['user']['account_id'])) + { + $filter['owner'] = $GLOBALS['egw_info']['user']['account_id']; + $filter['private'] = (int)($user == self::PRIVATE_AB); + } + + $messagelist = array(); + $criteria = null; + if (($contacts =& $this->addressbook->search($criteria, 'contact_id,contact_etag', '', '', '', + false, 'AND', false,$filter))) + { + foreach($contacts as $contact) + { + $messagelist[] = $this->StatMessage($id, $contact); + } + } + //error_log(__METHOD__."('$id', $cutoffdate) filter=".array2string($filter)." returning ".count($messagelist).' entries'); + return $messagelist; + } + + /** + * Get specified item from specified folder. + * + * @param string $folderid + * @param string $id + * @param ContentParameters $contentparameters parameters of the requested message (truncation, mimesupport etc) + * object with attributes foldertype, truncation, rtftruncation, conflict, filtertype, bodypref, deletesasmoves, filtertype, contentclass, mimesupport, conversationmode + * bodypref object with attributes: ]truncationsize, allornone, preview + * @return $messageobject|boolean false on error + */ + public function GetMessage($folderid, $id, $contentparameters) + { + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + //$truncsize = Utils::GetTruncSize($contentparameters->GetTruncation()); + //$mimesupport = $contentparameters->GetMimeSupport(); + $bodypreference = $contentparameters->GetBodyPreference(); /* fmbiete's contribution r1528, ZP-320 */ + //debugLog (__METHOD__."('$folderid', $id, ...) truncsize=$truncsize, mimesupport=$mimesupport, bodypreference=".array2string($bodypreference)); + + $type = $account = null; + $this->backend->splitID($folderid, $type, $account); + if ($type != 'addressbook' || !($contact = $this->addressbook->read($id))) + { + error_log(__METHOD__."('$folderid',$id,...) Folder wrong (type=$type, account=$account) or contact not existing (read($id)=".array2string($contact).")! returning false"); + return false; + } + $emailname = isset($contact['n_given']) ? $contact['n_given'].' ' : ''; + $emailname .= isset($contact['n_middle']) ? $contact['n_middle'].' ' : ''; + $emailname .= isset($contact['n_family']) ? $contact['n_family']: ''; + $message = new SyncContact(); + foreach(self::$mapping as $key => $attr) + { + switch ($attr) + { + case 'note': + if ($bodypreference == false) + { + $message->body = $contact[$attr]; + $message->bodysize = strlen($message->body); + $message->bodytruncated = 0; + } + else + { + if (strlen ($contact[$attr]) > 0) + { + $message->asbody = new SyncBaseBody(); + $this->backend->note2messagenote($contact[$attr], $bodypreference, $message->asbody); + } + } + break; + + case 'jpegphoto': + if (!empty($contact[$attr])) $message->$key = base64_encode($contact[$attr]); + break; + + case 'bday': // zpush seems to use a timestamp in utc (at least vcard backend does) + if (!empty($contact[$attr])) + { + $tz = date_default_timezone_get(); + date_default_timezone_set('UTC'); + $message->birthday = strtotime($contact[$attr]); + date_default_timezone_set($tz); + } + break; + + case 'cat_id': + $message->$key = array(); + foreach($contact[$attr] ? explode(',',$contact[$attr]) : array() as $cat_id) + { + $message->categories[] = categories::id2name($cat_id); + } + // for all addressbooks in one, add addressbook name itself as category + if ($GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one']) + { + $message->categories[] = $this->get_addressbooks($contact['owner'].($contact['private']?'p':''), false, true); + } + break; + case 'email': + case 'email_home': + if (!empty($contact[$attr])) + { + $message->$key = ('"'.$emailname.'"'." <$contact[$attr]>"); + } + break; + // HTC Desire needs at least one telefon number, otherwise sync of contact fails without error, + // but will be retired forerver --> we always return work-phone xml element, even if it's empty + // (Mircosoft ActiveSync Contact Class Protocol Specification says all phone-numbers are optional!) + case 'tel_work': + $message->$key = (string)$contact[$attr]; + break; + case 'n_fileas': + if ($GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-force-fileas']) + { + $message->$key = $this->addressbook->fileas($contact,$GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-force-fileas']); + break; + } + // fall through + default: + if (!empty($contact[$attr])) $message->$key = $contact[$attr]; + } + } + //error_log(__METHOD__."(folder='$folderid',$id,...) returning ".array2string($message)); + return $message; + } + + /** + * StatMessage should return message stats, analogous to the folder stats (StatFolder). Entries are: + * 'id' => Server unique identifier for the message. Again, try to keep this short (under 20 chars) + * 'flags' => simply '0' for unread, '1' for read + * 'mod' => modification signature. As soon as this signature changes, the item is assumed to be completely + * changed, and will be sent to the PDA as a whole. Normally you can use something like the modification + * time for this field, which will change as soon as the contents have changed. + * + * @param string $folderid + * @param int|array $contact contact id or array + * @return array + */ + public function StatMessage($folderid, $contact) + { + unset($folderid); // not used (contact_id is global), but required by function signaure + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + if (!is_array($contact)) $contact = $this->addressbook->read($contact); + + if (!$contact) + { + $stat = false; + } + else + { + $stat = array( + 'mod' => $contact['etag'], + 'id' => $contact['id'], + 'flags' => 1, + ); + } + //debugLog (__METHOD__."('$folderid',".array2string($id).") returning ".array2string($stat)); + //error_log(__METHOD__."('$folderid',$contact) returning ".array2string($stat)); + return $stat; + } + + /** + * Creates or modifies a folder + * + * @param $id of the parent folder + * @param $oldid => if empty -> new folder created, else folder is to be renamed + * @param $displayname => new folder name (to be created, or to be renamed to) + * @param type => folder type, ignored in IMAP + * + * @return stat | boolean false on error + * + */ + public function ChangeFolder($id, $oldid, $displayname, $type) + { + unset($id, $oldid, $displayname, $type); // not used, but required by function signature + debugLog(__METHOD__." not implemented"); + } + + /** + * Deletes (really delete) a Folder + * + * @param $parentid of the folder to delete + * @param $id of the folder to delete + * + * @return + * @TODO check what is to be returned + * + */ + public function DeleteFolder($parentid, $id) + { + unset($parentid, $id); + debugLog(__METHOD__." not implemented"); + } + + /** + * Changes or adds a message on the server + * + * @param string $folderid + * @param int $id for change | empty for create new + * @param SyncContact $message object to SyncObject to create + * @param ContentParameters $contentParameters + * + * @return array $stat whatever would be returned from StatMessage + * + * This function is called when a message has been changed on the PDA. You should parse the new + * message here and save the changes to disk. The return value must be whatever would be returned + * from StatMessage() after the message has been saved. This means that both the 'flags' and the 'mod' + * properties of the StatMessage() item may change via ChangeMessage(). + * Note that this function will never be called on E-mail items as you can't change e-mail items, you + * can only set them as 'read'. + */ + public function ChangeMessage($folderid, $id, $message, $contentParameters) + { + unset($contentParameters); // not used, but required by function signature + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + $type = $account = null; + $this->backend->splitID($folderid, $type, $account); + $is_private = false; + if ($account == self::PRIVATE_AB) + { + $account = $GLOBALS['egw_info']['user']['account_id']; + $is_private = true; + + } + // error_log(__METHOD__. " Id " .$id. " Account ". $account . " FolderID " . $folderid); + if ($type != 'addressbook') // || !($contact = $this->addressbook->read($id))) + { + debugLog(__METHOD__." Folder wrong or contact not existing"); + return false; + } + if ($account == 0) // as a precausion, we currently do NOT allow to change accounts + { + debugLog(__METHOD__." Changing of accounts denied!"); + return false; //no changing of accounts + } + $contact = array(); + if (empty($id) && ($this->addressbook->grants[$account] & EGW_ACL_EDIT) || ($contact = $this->addressbook->read($id)) && $this->addressbook->check_perms(EGW_ACL_EDIT, $contact)) + { + // remove all fields supported by AS, leaving all unsupported fields unchanged + $contact = array_diff_key($contact, array_flip(self::$mapping)); + foreach (self::$mapping as $key => $attr) + { + switch ($attr) + { + case 'note': + $contact[$attr] = $this->backend->messagenote2note($message->body, $message->rtf, $message->asbody); + break; + + case 'bday': // zpush uses timestamp in servertime + $contact[$attr] = $message->$key ? date('Y-m-d',$message->$key) : null; + break; + + case 'jpegphoto': + $contact[$attr] = base64_decode($message->$key); + break; + + case 'cat_id': + // for existing entries in all-in-one addressbook, remove addressbook name as category + if ($contact && $GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one'] && + ($k=array_search($this->get_addressbooks($contact['owner'].($contact['private']?'p':''), false, true),$message->$key))) + { + unset($message->categories[$k]); + } + if (is_array($message->$key)) + { + $contact[$attr] = implode(',', array_filter($this->addressbook->find_or_add_categories($message->$key, $id),'strlen')); + } + break; + case 'email': + case 'email_home': + if (function_exists ('imap_rfc822_parse_adrlist')) + { + $email_array = array_shift(imap_rfc822_parse_adrlist($message->$key,"")); + if (!empty($email_array->mailbox) && $email_array->mailbox != 'INVALID_ADDRESS' && !empty($email_array->host)) + { + $contact[$attr] = $email_array->mailbox.'@'.$email_array->host; + } + else + { + $contact[$attr] = $message->$key; + } + } + else + { + debugLog(__METHOD__. " Warning : php-imap not available"); + $contact[$attr] = $message->$key; + } + break; + case 'n_fileas': // only change fileas, if not forced on the client + if (!$GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-force-fileas']) + { + $contact[$attr] = $message->$key; + } + break; + case 'title': // as ol jobtitle mapping changed in egw from role to title, do NOT overwrite title with value of role + if ($id && $message->$key == $contact['role']) break; + // fall throught + default: + $contact[$attr] = $message->$key; + break; + } + } + // for all-in-one addressbook, account is meaningless and wrong! + // addressbook_bo::save() keeps the owner or sets an appropriate one if none given + if (!isset($contact['private'])) $contact['private'] = (int)$is_private; + if (!$GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one']) + { + $contact['owner'] = $account; + $contact['private'] = (int)$is_private; + } + // if default addressbook for new contacts is NOT synced --> use personal addressbook + elseif($GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'] && + !in_array($GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'], + array_keys($this->get_addressbooks(null,false)))) + { + $contact['owner'] = $GLOBALS['egw_info']['user']['account_id']; + } + if (!empty($id)) $contact['id'] = $id; + $this->addressbook->fixup_contact($contact); + $newid = $this->addressbook->save($contact); + //error_log(__METHOD__."($folderid,$id) contact=".array2string($contact)." returning ".array2string($newid)); + return $this->StatMessage($folderid, $newid); + } + debugLog(__METHOD__."($folderid, $id) returning false: Permission denied"); + return false; + } + + /** + * Moves a message from one folder to another + * + * @param $folderid of the current folder + * @param $id of the message + * @param $newfolderid + * @param ContentParameters $contentParameters + * + * @return $newid as a string | boolean false on error + * + * After this call, StatMessage() and GetMessageList() should show the items + * to have a new parent. This means that it will disappear from GetMessageList() will not return the item + * at all on the source folder, and the destination folder will show the new message + * + * @ToDo: If this gets implemented, we have to take into account the 'addressbook-all-in-one' pref! + */ + public function MoveMessage($folderid, $id, $newfolderid, $contentParameters) + { + unset($contentParameters); // not used, but required by function signature + if ($GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one']) + { + debugLog(__METHOD__."('$folderid', $id, $newfolderid) NOT allowed for an all-in-one addressbook --> returning false"); + return false; + } + debugLog(__METHOD__."('$folderid', $id, $newfolderid) NOT implemented --> returning false"); + return false; + } + + + /** + * Delete (really delete) a message in a folder + * + * @param $folderid + * @param $id + * @param ContentParameters $contentParameters + * + * @return boolean true on success, false on error, diffbackend does NOT use the returnvalue + * + * @DESC After this call has succeeded, a call to + * GetMessageList() should no longer list the message. If it does, the message will be re-sent to the PDA + * as it will be seen as a 'new' item. This means that if you don't implement this function, you will + * be able to delete messages on the PDA, but as soon as you sync, you'll get the item back + */ + public function DeleteMessage($folderid, $id, $contentParameters) + { + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + $ret = $this->addressbook->delete($id); + debugLog(__METHOD__."('$folderid', $id) delete($id) returned ".array2string($ret)); + return $ret; + } + + /** + * Changes the 'read' flag of a message on disk. The $flags + * parameter can only be '1' (read) or '0' (unread). After a call to + * SetReadFlag(), GetMessageList() should return the message with the + * new 'flags' but should not modify the 'mod' parameter. If you do + * change 'mod', simply setting the message to 'read' on the mobile will trigger + * a full resync of the item from the server. + * + * @param string $folderid id of the folder + * @param string $id id of the message + * @param int $flags read flag of the message + * @param ContentParameters $contentParameters + * + * @access public + * @return boolean status of the operation + * @throws StatusException could throw specific SYNC_STATUS_* exceptions + */ + function SetReadFlag($folderid, $id, $flags, $contentParameters) + { + unset($folderid, $id, $flags, $contentParameters); + return false; + } + + /** + * modify olflags (outlook style) flag of a message + * + * @param $folderid + * @param $id + * @param $flags + * + * + * @DESC The $flags parameter must contains the poommailflag Object + */ + function ChangeMessageFlag($folderid, $id, $flags) + { + unset($folderid, $id, $flags); + return false; + } + + /** + * Return a changes array + * + * if changes occurr default diff engine computes the actual changes + * + * @param string $folderid + * @param string &$syncstate on call old syncstate, on return new syncstate + * @return array|boolean false if $folderid not found, array() if no changes or array(array("type" => "fakeChange")) + */ + function AlterPingChanges($folderid, &$syncstate) + { + $type = $owner = null; + $this->backend->splitID($folderid, $type, $owner); + + if ($type != 'addressbook') return false; + + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + + // handle all-in-one addressbook + if ($GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-all-in-one'] && + $owner == $GLOBALS['egw_info']['user']['account_id']) + { + if (strpos($GLOBALS['egw_info']['user']['preferences']['activesync']['addressbook-abs'],'A') !== false) + { + $owner = null; // all AB's + } + else + { + $owner = array_keys($this->get_addressbooks(null,false)); // false = return all selected abs + // translate AS private AB ID to current user + if (($key = array_search(self::PRIVATE_AB, $owner)) !== false) + { + unset($owner[$key]); + if (!in_array($GLOBALS['egw_info']['user']['account_id'],$owner)) + { + $owner[] = $GLOBALS['egw_info']['user']['account_id']; + } + } + } + } + if ($owner == self::PRIVATE_AB) + { + $owner = $GLOBALS['egw_info']['user']['account_id']; + } + $ctag = $this->addressbook->get_ctag($owner); + + $changes = array(); // no change + //$syncstate_was = $syncstate; + + if ($ctag !== $syncstate) + { + $syncstate = $ctag; + $changes = array(array('type' => 'fakeChange')); + } + //error_log(__METHOD__."('$folderid','$syncstate_was') syncstate='$syncstate' returning ".array2string($changes)); + return $changes; + } + + /** + * Search global address list for a given pattern + * + * @param array $searchquery value for keys 'query' and 'range' (eg. "0-50") + * @return array with just rows (no values for keys rows, status or global_search_status!) + * @todo search range not verified, limits might be a good idea + */ + function getSearchResultsGAL($searchquery) + { + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + //error_log(__METHOD__.'('.array2string($searchquery).')'); + + // only return items in given range, eg. "0-50" + $range = false; + if (isset($searchquery['range']) && preg_match('/^\d+-\d+$/', $searchquery['range'])) + { + list($start,$end) = explode('-', $searchquery['range']); + $range = array($start, $end-$start+1); // array(start, num_entries) + } + //error_log(__METHOD__.'('.array2string($searchquery).') range='.array2string($range)); + + $items = array(); + if (($contacts =& $this->addressbook->search($searchquery['query'], false, false, '', '%', false, 'OR', $range))) + { + foreach($contacts as $contact) + { + $item['username'] = $contact['n_family']; + $item['fullname'] = $contact['n_fn']; + if (!trim($item['fullname'])) $item['fullname'] = $item['username']; + $item['emailaddress'] = $contact['email'] ? $contact['email'] : (string)$contact['email_private'] ; + $item['nameid'] = $searchquery; + $item['phone'] = (string)$contact['tel_work']; + $item['homephone'] = (string)$contact['tel_home']; + $item['mobilephone'] = (string)$contact['tel_cell']; + $item['company'] = (string)$contact['org_name']; + $item['office'] = $contact['room']; + $item['title'] = $contact['title']; + + //do not return users without email + if (!trim($item['emailaddress'])) continue; + + $items[] = $item; + } + } + return $items; + } + + /** + * Populates $settings for the preferences + * + * @param array|string $hook_data + * @return array + */ + function egw_settings($hook_data) + { + $addressbooks = array(); + + if (!isset($hook_data['setup']) && in_array($hook_data['type'], array('user', 'group'))) + { + $user = $hook_data['account_id']; + $addressbook_bo = new addressbook_bo(); + $addressbooks = $addressbook_bo->get_addressbooks(EGW_ACL_READ, null, $user); + if ($user > 0) + { + unset($addressbooks[$user]); // personal addressbook is allways synced + if (isset($addressbooks[$user.'p'])) + { + $addressbooks[self::PRIVATE_AB] = lang('Private'); + } + } + unset($addressbooks[$user.'p']);// private addressbook uses ID self::PRIVATE_AB + $fileas_options = array('0' => lang('use addressbooks "own sorting" attribute'))+$addressbook_bo->fileas_options(); + } + $addressbooks += array( + 'G' => lang('Primary Group'), + 'U' => lang('Accounts'), + 'A' => lang('All'), + ); + // allow to force "none", to not show the prefs to the users + if ($hook_data['type'] == 'forced') + { + $addressbooks['N'] = lang('None'); + } + + // rewriting owner=0 to 'U', as 0 get's always selected by prefs + // not removing it for default or forced prefs based on current users pref + if (!isset($addressbooks[0]) && (in_array($hook_data['type'], array('user', 'group')) || + $GLOBALS['egw_info']['user']['preferences']['addressbook']['hide_accounts'])) + { + unset($addressbooks['U']); + } + else + { + unset($addressbooks[0]); + } + + $settings['addressbook-abs'] = array( + 'type' => 'multiselect', + 'label' => 'Additional addressbooks to sync', + 'name' => 'addressbook-abs', + 'help' => 'Global address search always searches in all addressbooks, so you dont need to sync all addressbooks to be able to access them, if you are online.', + 'values' => $addressbooks, + 'xmlrpc' => True, + 'admin' => False, + ); + + $settings['addressbook-all-in-user'] = array( + 'type' => 'check', + 'label' => 'Sync all addressbooks as one', + 'name' => 'addressbook-all-in-one', + 'help' => 'Not all devices support multiple addressbooks, so you can choose to sync all above selected addressbooks as one.', + 'xmlrpc' => true, + 'admin' => false, + 'default' => '0', + ); + + $settings['addressbook-force-fileas'] = array( + 'type' => 'select', + 'label' => 'Force sorting on device to', + 'name' => 'addressbook-force-fileas', + 'help' => 'Some devices (eg. Windows Mobil, but not iOS) sort by addressbooks "own sorting" attribute, which might not be what you want on the device. With this setting you can force the device to use a different sorting for all contacts, without changing it in addressbook.', + 'values' => $fileas_options, + 'xmlrpc' => true, + 'admin' => false, + 'default' => '0', + ); + return $settings; + } +} diff --git a/calendar/inc/class.calendar_zpush.inc.php b/calendar/inc/class.calendar_zpush.inc.php new file mode 100644 index 0000000000..bc12e6c957 --- /dev/null +++ b/calendar/inc/class.calendar_zpush.inc.php @@ -0,0 +1,1685 @@ + + * @author Klaus Leithoff + * @author Philip Herbert + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @version $Id$ + */ + +/** + * Required for TZID <--> AS timezone blog test, if script is called directly via URL + */ +if (isset($_SERVER['SCRIPT_FILENAME']) && $_SERVER['SCRIPT_FILENAME'] == __FILE__) +{ + interface activesync_plugin_write {} + interface activesync_plugin_meeting_requests {} +} + +/** + * Calendar eSync plugin + * + * Plugin to make EGroupware calendar data available + * + * Handling of (virtual) exceptions of recurring events: + * ---------------------------------------------------- + * Virtual exceptions are exceptions caused by recurcences with just different participant status + * compared to regular series (master). Real exceptions usually have different dates and/or other data. + * EGroupware calendar data model does NOT store virtual exceptions as exceptions, + * as participant status is stored per recurrence date and not just per event! + * + * --> ActiveSync protocol does NOT support different participants or status for exceptions! + * + * Handling of alarms: + * ------------------ + * We report only alarms of the current user (which should ring on the device) + * and save alarms set on the device only for the current user, if not yet there (preserving all other alarms). + * How to deal with multiple alarms allowed in EGroupware: report earliest one to the device + * (and hope it resyncs before next one is due, thought we do NOT report that as change currently!). + */ +class calendar_activesync implements activesync_plugin_write, activesync_plugin_meeting_requests +{ + /** + * var BackendEGW + */ + private $backend; + + /** + * Instance of calendar_bo + * + * @var calendar_boupdate + */ + private $calendar; + + /** + * Instance of addressbook_bo + * + * @var addressbook_bo + */ + private $addressbook; + + /** + * Constructor + * + * @param BackendEGW $backend + */ + public function __construct(BackendEGW $backend) + { + $this->backend = $backend; + } + + /** + * This function is analogous to GetMessageList. + */ + public function GetFolderList() + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + $cals = $GLOBALS['egw_info']['user']['preferences']['activesync']['calendar-cals']; + $cals = $cals ? explode(',',$cals) : array('P'); // implicit default of 'P' + $folderlist = array(); + + foreach ($this->calendar->list_cals() as $entry) + { + $account_id = $entry['grantor']; + $label = $entry['name']; + if (in_array('A',$cals) || in_array($account_id,$cals) || + $account_id == $GLOBALS['egw_info']['user']['account_id'] || // always incl. own calendar! + $account_id == $GLOBALS['egw_info']['user']['account_primary_group'] && in_array('G',$cals)) + { + $folderlist[] = $f = array( + 'id' => $this->backend->createID('calendar',$account_id), + 'mod' => $GLOBALS['egw']->accounts->id2name($account_id,'account_fullname'), + 'parent'=> '0', + ); + } + }; + //error_log(__METHOD__."() returning ".array2string($folderlist)); + debugLog(__METHOD__."() returning ".array2string($folderlist)); + return $folderlist; + } + + /** + * Get Information about a folder + * + * @param string $id + * @return SyncFolder|boolean false on error + */ + public function GetFolder($id) + { + $this->backend->splitID($id, $type, $owner); + + $folderObj = new SyncFolder(); + $folderObj->serverid = $id; + $folderObj->parentid = '0'; + $folderObj->displayname = $GLOBALS['egw']->accounts->id2name($owner,'account_fullname'); + if ($owner == $GLOBALS['egw_info']['user']['account_id']) + { + $folderObj->type = SYNC_FOLDER_TYPE_APPOINTMENT; + } + else + { + $folderObj->type = SYNC_FOLDER_TYPE_USER_APPOINTMENT; + } + //error_log(__METHOD__."('$id') folderObj=".array2string($folderObj)); + //debugLog(__METHOD__."('$id') folderObj=".array2string($folderObj)); + return $folderObj; + } + + /** + * Return folder stats. This means you must return an associative array with the + * following properties: + * + * "id" => The server ID that will be used to identify the folder. It must be unique, and not too long + * How long exactly is not known, but try keeping it under 20 chars or so. It must be a string. + * "parent" => The server ID of the parent of the folder. Same restrictions as 'id' apply. + * "mod" => This is the modification signature. It is any arbitrary string which is constant as long as + * the folder has not changed. In practice this means that 'mod' can be equal to the folder name + * as this is the only thing that ever changes in folders. (the type is normally constant) + * + * @return array with values for keys 'id', 'mod' and 'parent' + */ + public function StatFolder($id) + { + $folder = $this->GetFolder($id); + $this->backend->splitID($id, $type, $owner); + + $stat = array( + 'id' => $id, + 'mod' => $GLOBALS['egw']->accounts->id2name($owner,'account_fullname'), + 'parent' => '0', + ); + //error_log(__METHOD__."('$id') folderObj=".array2string($stat)); + //debugLog(__METHOD__."('$id') folderObj=".array2string($stat)); + return $stat; + } + + /** + * Should return a list (array) of messages, each entry being an associative array + * with the same entries as StatMessage(). This function should return stable information; ie + * if nothing has changed, the items in the array must be exactly the same. The order of + * the items within the array is not important though. + * + * The cutoffdate is a date in the past, representing the date since which items should be shown. + * This cutoffdate is determined by the user's setting of getting 'Last 3 days' of e-mail, etc. If + * you ignore the cutoffdate, the user will not be able to select their own cutoffdate, but all + * will work OK apart from that. + * + * @param string $id folder id + * @param int $cutoffdate=null + * @param array $not_uids=null uids NOT to return for meeting requests + * @return array + */ + function GetMessageList($id, $cutoffdate=NULL, array $not_uids=null) + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + debugLog (__METHOD__."('$id',$cutoffdate)"); + $this->backend->splitID($id,$type,$user); + + if (!$cutoffdate) $cutoffdate = $this->bo->now - 100*24*3600; // default three month back -30 breaks all sync recurrences + + $filter = array( + 'users' => $user, + 'start' => $cutoffdate, // default one month back -30 breaks all sync recurrences + 'enum_recuring' => false, + 'daywise' => false, + 'date_format' => 'server', + // default = not rejected, current user return NO meeting requests (status=unknown), as they are returned via email! + //'filter' => $user == $GLOBALS['egw_info']['user']['account_id'] ? (is_array($not_uids) ? 'unknown' : 'not-unknown') : 'default', + 'filter' => $user == $GLOBALS['egw_info']['user']['account_id'] ? (is_array($not_uids) ? 'unknown' : 'default') : 'default', + // @todo return only etag relevant information (seems not to work ...) + //'cols' => array('egw_cal.cal_id', 'cal_start', 'recur_type', 'cal_modified', 'cal_uid', 'cal_etag'), + 'query' => array('cal_recurrence' => 0), // do NOT return recurrence exceptions + ); + + $messagelist = array(); + foreach ($this->calendar->search($filter) as $event) + { + if ($not_uids && in_array($event['uid'], $not_uids)) continue; + $messagelist[] = $this->StatMessage($id, $event); + + // add virtual exceptions for recuring events too + // (we need to read event, as get_recurrence_exceptions need all infos!) +/* if ($event['recur_type'] != calendar_rrule::NONE)// && ($event = $this->calendar->read($event['id'],0,true,'server'))) + { + + foreach($this->calendar->so->get_recurrence_exceptions($event, + egw_time::$server_timezone->getName(), $cutoffdate, 0, 'all') as $recur_date) + { + $messagelist[] = $this->StatMessage($id, $event['id'].':'.$recur_date); + } + }*/ + } + return $messagelist; + } + + /** + * List all meeting requests / invitations of user NOT having a UID in $not_uids (already received by email) + * + * @param array $not_uids + * @param int $cutoffdate=null + * @return array + */ + function GetMeetingRequests(array $not_uids, $cutoffdate=NULL) + { +return array(); // temporary disabling meeting requests from calendar + $folderid = $this->backend->createID('calendar', $GLOBALS['egw_info']['user']['account_id']); // users personal calendar + + $ret = $this->GetMessageList($folderid, $cutoffdate, $not_uids); + // return all id's negative to not conflict with uids from fmail + foreach($ret as &$message) + { + $message['id'] = -$message['id']; + } + + debugLog(__METHOD__.'('.array2string($not_uids).", $cutoffdate) returning ".array2string($ret)); + return $ret; + } + + /** + * Stat a meeting request + * + * @param int $id negative! id + * @return array + */ + function StatMeetingRequest($id) + { + $folderid = $this->backend->createID('calendar', $GLOBALS['egw_info']['user']['account_id']); // users personal calendar + + $ret = $this->StatMessage($folderid, abs($id)); + $ret['id'] = $id; + + debugLog(__METHOD__."($id) returning ".array2string($ret)); + return $ret; + } + + /** + * Return a meeting request as AS SyncMail object + * + * @param int $id negative! cal_id + * @param int $truncsize + * @param int $bodypreference + * @param $optionbodypreference + * @param bool $mimesupport + * @return SyncMail + */ + function GetMeetingRequest($id, $truncsize, $bodypreference=false, $optionbodypreference=false, $mimesupport = 0) + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + if (!($event = $this->calendar->read(abs($id), 0, false, 'server'))) + { + $message = false; + } + else + { + $message = new SyncMail(); + $message->read = false; + $message->subject = $event['title']; + $message->importance = 1; // 0=Low, 1=Normal, 2=High + $message->datereceived = $event['created']; + $message->to = $message->displayto = $GLOBALS['egw_info']['user']['account_email']; + $message->from = $GLOBALS['egw']->accounts->id2name($event['owner'],'account_fullname'). + ' <'.$GLOBALS['egw']->accounts->id2name($event['owner'],'account_email').'>'; + $message->internetcpid = 65001; + $message->contentclass="urn:content-classes:message"; + + $message->meetingrequest = self::meetingRequest($event); + $message->messageclass = "IPM.Schedule.Meeting.Request"; + + // add description as message body + if ($bodypreference == false) + { + $message->body = $event['description']; + $message->bodysize = strlen($message->body); + $message->bodytruncated = 0; + } + else + { + $message->airsyncbasebody = new SyncAirSyncBaseBody(); + $message->airsyncbasenativebodytype=1; + $this->backend->note2messagenote($event['description'], $bodypreference, $message->airsyncbasebody); + } + } + debugLog(__METHOD__."($id) returning ".array2string($message)); + return $message; + } + + /** + * Generate SyncMeetingRequest object from an event array + * + * Used by (calendar|felamimail)_activesync + * + * @param array|string $event event array or string with iCal + * @return SyncMeetingRequest or null ical not parsable + */ + public static function meetingRequest($event) + { + if (!is_array($event)) + { + $ical = new calendar_ical(); + if (!($events = $ical->icaltoegw($event, '', 'utf-8')) || count($events) != 1) + { + debugLog(__METHOD__."('$event') error parsing iCal!"); + return null; + } + $event = array_shift($events); + debugLog(__METHOD__."(...) parsed as ".array2string($event)); + } + $message = new SyncMeetingRequest(); + // set timezone + try { + $as_tz = self::tz2as($event['tzid']); + $message->timezone = base64_encode(calendar_activesync::_getSyncBlobFromTZ($as_tz)); + } + catch(Exception $e) { + // ignore exception, simply set no timezone, as it is optional + } + // copying timestamps (they are already read in servertime, so non tz conversation) + foreach(array( + 'start' => 'starttime', + 'end' => 'endtime', + 'created' => 'dtstamp', + ) as $key => $attr) + { + if (!empty($event[$key])) $message->$attr = $event[$key]; + } + if (($message->alldayevent = (int)calendar_bo::isWholeDay($event))) + { + ++$message->endtime; // EGw all-day-events are 1 sec shorter! + } + // copying strings + foreach(array( + 'title' => 'subject', + 'location' => 'location', + ) as $key => $attr) + { + if (!empty($event[$key])) $message->$attr = $event[$key]; + } + $message->organizer = $event['organizer']; + + $message->sensitivity = $event['public'] ? 0 : 2; // 0=normal, 1=personal, 2=private, 3=confidential + + // busystatus=(0=free|1=tentative|2=busy|3=out-of-office), EGw has non_blocking=0|1 + $message->busystatus = $event['non_blocking'] ? 0 : 2; + + // ToDo: recurring events: InstanceType, RecurrenceId, Recurrences; ... + $message->instancetype = 0; // 0=Single, 1=Master recurring, 2=Single recuring, 3=Exception + + $message->responserequested = 1; //0=No, 1=Yes + $message->disallownewtimeproposal = 1; //1=forbidden, 0=allowed + //$message->messagemeetingtype; // email2 + + // ToDo: alarme: Reminder + + // convert UID to GlobalObjID + $message->globalobjid = BackendEGW::uid2globalObjId($event['uid']); + + return $message; + } + + /** + * Process response to meeting request + * + * @see BackendDiff::MeetingResponse() + * @param string $folderid folder of meeting request mail + * @param int|string $requestid cal_id, or string with iCal from fmail plugin + * @param int $response 1=accepted, 2=tentative, 3=decline + * @return int|boolean id of calendar item, false on error + */ + function MeetingResponse($folderid, $requestid, $response) + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + static $as2status = array( // different from self::$status2as! + 1 => 'A', + 2 => 'T', + 3 => 'D', + ); + $status = isset($as2status[$response]) ? $as2status[$response] : 'U'; + $uid = $GLOBALS['egw_info']['user']['account_id']; + + if (!is_numeric($requestid)) // iCal from fmail + { + $ical = new calendar_ical(); + if (!($events = $ical->icaltoegw($requestid, '', 'utf-8')) || count($events) != 1) + { + debugLog(__METHOD__."('$event') error parsing iCal!"); + return null; + } + $parsed_event = array_shift($events); + debugLog(__METHOD__."(...) parsed as ".array2string($parsed_event)); + + // check if event already exist (invitation of or already imported by other user) + if (!($event = $this->calendar->read($parsed_event['uid'], 0, false, 'server'))) + { + $event = $parsed_event; // create new event from external invitation + } + elseif(!isset($event['participants'][$uid])) + { + debugLog(__METHOD__.'('.array2string($requestid).", $folderid, $response) current user ($uid) is NO participant of event ".array2string($event)); + // maybe we should silently add him, as he might not have the rights to add him himself with calendar->update ... + } + elseif($event['deleted']) + { + debugLog(__METHOD__.'('.array2string($requestid).", $folderid, $response) event ($uid) deleted on server --> return false"); + return false; + } + } + elseif (!($event = $this->calendar->read(abs($requestid), 0, false, 'server'))) + { + debugLog(__METHOD__."('$requestid', '$folderid', $response) returning FALSE"); + return false; + } + // keep role and quantity as AS has no idea about it + calendar_so::split_status($event['participants'][$uid], $quantity, $role); + $status = calendar_so::combine_status($status,$quantity,$role); + + if ($event['id'] && isset($event['participants'][$uid])) + { + $ret = $this->calendar->set_status($event, $uid, $status) ? $event['id'] : false; + } + else + { + $event['participants'][$uid] = $status; + $ret = $this->calendar->update($event, true); // true = ignore conflicts, as there seems no conflict handling in AS + } + debugLog(__METHOD__.'('.array2string($requestid).", '$folderid', $response) returning ".array2string($ret)); + return $ret; + } + + /** + * Conversation to AS status + * + * @var array + */ + static $status2as = array( + 'U' => 0, // unknown + 'T' => 2, // tentative + 'A' => 3, // accepted + 'R' => 4, // decline + // 5 = not responded + ); + /** + * Conversation to AS "roles", not really the same thing + * + * @var array + */ + static $role2as = array( + 'REQ-PARTICIPANT' => 1, // required + 'CHAIR' => 1, // required + 'OPT-PARTICIPANT' => 2, // optional + 'NON-PARTICIPANT' => 2, + // 3 = ressource + ); + /** + * Conversation to AS recurrence types + * + * @var array + */ + static $recur_type2as = array( + calendar_rrule::DAILY => 0, + calendar_rrule::WEEKLY => 1, + calendar_rrule::MONTHLY_MDAY => 2, // monthly + calendar_rrule::MONTHLY_WDAY => 3, // monthly on nth day + calendar_rrule::YEARLY => 5, + // 6 = yearly on nth day (same as 5 on non-leapyears or before March on leapyears) + ); + + /** + * Changes or adds a message on the server + * + * Timestamps from z-push are in servertime and need to get converted to user-time, as bocalendar_update::save() + * expects user-time! + * + * @param string $folderid + * @param int $id for change | empty for create new + * @param SyncAppointment $message object to SyncObject to create + * + * @return array $stat whatever would be returned from StatMessage + * + * This function is called when a message has been changed on the PDA. You should parse the new + * message here and save the changes to disk. The return value must be whatever would be returned + * from StatMessage() after the message has been saved. This means that both the 'flags' and the 'mod' + * properties of the StatMessage() item may change via ChangeMessage(). + * Note that this function will never be called on E-mail items as you can't change e-mail items, you + * can only set them as 'read'. + */ + public function ChangeMessage($folderid, $id, $message) + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + $event = array(); + $this->backend->splitID($folderid, $type, $account); + + debugLog (__METHOD__."('$folderid', $id, ".array2string($message).") type='$type', account=$account"); + + list($id,$recur_date) = explode(':',$id); + + if ($type != 'calendar' || $id && !($event = $this->calendar->read($id, $recur_date, false, 'server'))) + { + debugLog(__METHOD__."('$folderid',$id,...) Folder wrong or event does not existing"); + return false; + } + if ($recur_date) // virtual exception + { + // @todo check if virtual exception needs to be saved as real exception, or only stati need to be changed + debugLog(__METHOD__."('$folderid',$id:$recur_date,".array2string($message).") handling of virtual exception not yet implemented!"); + error_log(__METHOD__."('$folderid',$id:$recur_date,".array2string($message).") handling of virtual exception not yet implemented!"); + } + if (!$this->calendar->check_perms($id ? EGW_ACL_EDIT : EGW_ACL_ADD,$event ? $event : 0,$account)) + { + // @todo: write in users calendar and make account only a participant + debugLog(__METHOD__."('$folderid',$id,...) no rights to add/edit event!"); + error_log(__METHOD__."('$folderid',$id,".array2string($message).") no rights to add/edit event!"); + return false; + } + if (!$id) $event['owner'] = $account; // we do NOT allow to change the owner of existing events + + $event = $this->message2event($message, $account, $event); + + // store event, ignore conflicts and skip notifications, as AS clients do their own notifications + $skip_notification = false; + if (isset($GLOBALS['egw_info']['user']['preferences']['activesync']['felamimail-allowSendingInvitations']) && + $GLOBALS['egw_info']['user']['preferences']['activesync']['felamimail-allowSendingInvitations']=='send') + { + $skip_notification = true; // to avoid double notification from client AND Server + } + if (!($id = $this->calendar->update($event,$ignore_conflicts=true,$touch_modified=true,$ignore_acl=false,$updateTS=true,$messages=null, $skip_notification))) + { + debugLog(__METHOD__."('$folderid',$id,...) error saving event=".array2string($event)."!"); + return false; + } + // store non-delete exceptions + if ($message->exceptions) + { + foreach($message->exceptions as $exception) + { + if (!$exception->deleted) + { + $ex_event = $event; + unset($ex_event['id']); + unset($ex_event['etag']); + foreach($ex_event as $name => $value) if (substr($name,0,6) == 'recur_') unset($ex_event[$name]); + $ex_event['recur_type'] = calendar_rrule::NONE; + + if ($event['id'] && ($ex_events = $this->calendar->search(array( + 'user' => $user, + 'enum_recuring' => false, + 'daywise' => false, + 'filter' => 'owner', // return all possible entries + 'query' => array( + 'cal_uid' => $event['uid'], + 'cal_recurrence' => $exception->exceptionstarttime, // in servertime + ), + )))) + { + $ex_event = array_shift($ex_events); + $participants = $ex_event['participants']; + } + else + { + $participants = $event['participants']; + } + $ex_event = $this->message2event($exception, $account, $ex_event); + $ex_event['participants'] = $participants; // not contained in $exception + $ex_event['reference'] = $event['id']; + $ex_event['recurrence'] = egw_time::server2user($exception->exceptionstarttime); + $ex_ok = $this->calendar->save($ex_event); + debugLog(__METHOD__."('$folderid',$id,...) saving exception=".array2string($ex_event).' returned '.array2string($ex_ok)); + error_log(__METHOD__."('$folderid',$id,...) exception=".array2string($exception).") saving exception=".array2string($ex_event).' returned '.array2string($ex_ok)); + } + } + } + debugLog(__METHOD__."('$folderid',$id,...) SUCESS saving event=".array2string($event).", id=$id"); + //error_log(__METHOD__."('$folderid',$id,".array2string($message).") SUCESS saving event=".array2string($event).", id=$id"); + return $this->StatMessage($folderid, $id); + } + + /** + * Parse AS message into EGw event array + * + * @param SyncAppointment $message + * @param int $account + * @param array $event=array() + * @return array + */ + private function message2event(SyncAppointment $message, $account, $event=array()) + { + // timestamps (created & modified are updated automatically) + foreach(array( + 'start' => 'starttime', + 'end' => 'endtime', + ) as $key => $attr) + { + $event[$key] = egw_time::server2user($message->$attr); + } + // copying strings + foreach(array( + 'title' => 'subject', + 'uid' => 'uid', + 'location' => 'location', + ) as $key => $attr) + { + if (isset($message->$attr)) $event[$key] = $message->$attr; + } + + // only change description, if one given, as iOS5 skips description in ChangeMessage after MeetingResponse + // --> we ignore empty / not set description, so description get no longer lost, but you cant empty it via eSync + if (($description = $this->backend->messagenote2note($message->body, $message->rtf, $message->airsyncbasebody))) + { + $event['description'] = $description; + } + $event['public'] = (int)($message->sensitivity < 1); // 0=normal, 1=personal, 2=private, 3=confidential + + // busystatus=(0=free|1=tentative|2=busy|3=out-of-office), EGw has non_blocking=0|1 + if (isset($message->busystatus)) + { + $event['non_blocking'] = $message->busystatus ? 0 : 1; + } + + if (($event['whole_day'] = $message->alldayevent)) + { + if ($event['end'] == $event['start']) $event['end'] += 24*3600; // some clients send equal start&end for 1day + $event['end']--; // otherwise our whole-day event code in save makes it one more day! + } + + $participants = array(); + foreach((array)$message->attendees as $attendee) + { + if ($attendee->type == 3) continue; // we can not identify resources and re-add them anyway later + + if (preg_match('/^noreply-(.*)-uid@egroupware.org$/',$attendee->email,$matches)) + { + $uid = $matches[1]; + } + elseif (!($uid = $GLOBALS['egw']->accounts->name2id($attendee->email,'account_email'))) + { + $search = array( + 'email' => $attendee->email, + 'email_home' => $attendee->email, + //'n_fn' => $attendee->name, // not sure if we want matches without email + ); + // search addressbook for participant + if (!isset($this->addressbook)) $this->addressbook = new addressbook_bo(); + if ((list($data) = $this->addressbook->search($search, + array('id','egw_addressbook.account_id as account_id','n_fn'), + 'egw_addressbook.account_id IS NOT NULL DESC, n_fn IS NOT NULL DESC', + '','',false,'OR'))) + { + $uid = $data['account_id'] ? (int)$data['account_id'] : 'c'.$data['id']; + } + elseif($attendee->name === $attendee->email || empty($attendee->name)) // dont store empty or email as name + { + $uid = 'e'.$attendee->email; + } + else // store just the email + { + $uid = 'e'.$attendee->name.' <'.$attendee->email.'>'; + } + } + // as status and (attendee-)type are optional, keep old status, quantity and role, if not specified + if ($event['id'] && isset($event['participants'][$uid])) + { + $status = $event['participants'][$uid]; + calendar_so::split_status($status, $quantity, $role); + //debugLog("old status for $uid is status=$status, quantity=$quantitiy, role=$role"); + } + // check if just email is an existing attendee (iOS returns email as name too!), keep it to keep status/role if not set + elseif ($event['id'] && (isset($event['participants'][$u='e'.$attendee->email]) || + (isset($event['participants'][$u='e'.$attendee->name.' <'.$attendee->email.'>'])))) + { + $status = $event['participants'][$u]; + calendar_so::split_status($status, $quantity, $role); + //debugLog("old status for $uid as $u is status=$status, quantity=$quantitiy, role=$role"); + } + else // set some defaults + { + $status = 'U'; + $quantitiy = 1; + $role = 'REQ-PARTICIPANT'; + //debugLog("default status for $uid is status=$status, quantity=$quantitiy, role=$role"); + } + if ($role == 'CHAIR') $chair_set = true; // by role from existing participant + + if (isset($attendee->attendeestatus) && ($s = array_search($attendee->attendeestatus,self::$status2as))) + { + $status = $s; + } + if ($attendee->email == $message->organizeremail) + { + $role = 'CHAIR'; + $chair_set = true; + } + elseif (isset($attendee->attendeetype) && + !($role == 'CHAIR' && !is_numeric($uid)) && // do not override our external ORGANIZER + ($r = array_search($attendee->attendeetype,self::$role2as)) && + (int)self::$role2as[$role] != $attendee->attendeetype) // if old role gives same type, use old role, as we have a lot more roles then AS + { + $role = $r; + } + //debugLog("-> status for $uid is status=$status ($s), quantity=$quantitiy, role=$role ($r)"); + $participants[$uid] = calendar_so::combine_status($status,$quantitiy,$role); + } + // if organizer is not already participant, add him as chair + if (($uid = $GLOBALS['egw']->accounts->name2id($message->organizeremail,'account_email')) && !isset($participants[$uid])) + { + $participants[$uid] = calendar_so::combine_status($uid == $GLOBALS['egw_info']['user']['account_id'] ? + 'A' : 'U',1,'CHAIR'); + $chair_set = true; + } + // preserve all resource types not account, contact or email (eg. resources) for existing events + // $account is also preserved, as AS does not add him as participant! + foreach((array)$event['participant_types'] as $type => $parts) + { + if (in_array($type,array('c','e'))) continue; // they are correctly representable in AS + + foreach($parts as $id => $status) + { + // accounts are represented correctly, but the event owner which is no participant in AS + if ($type == 'u' && $id != $account) continue; + + $uid = calendar_so::combine_user($type, $id); + if (!isset($participants[$uid])) + { + $participants[$uid] = $status; + } + } + } + // add calendar owner as participant, as otherwise event will NOT be in his calendar, in which it was posted + if (!$event['id'] || !$participants || !isset($participants[$account])) + { + $participants[$account] = calendar_so::combine_status($account == $GLOBALS['egw_info']['user']['account_id'] ? + 'A' : 'U',1,!$chair_set ? 'CHAIR' : 'REQ-PARTICIPANT'); + } + $event['participants'] = $participants; + + if (isset($message->categories)) + { + $event['category'] = implode(',', array_filter($this->calendar->find_or_add_categories($message->categories, $event),'strlen')); + } + + // check if event is recurring and import recur information (incl. timezone) + if ($message->recurrence) + { + if ($message->timezone && !$event['id']) // dont care for timezone, if no new and recurring event + { + $event['tzid'] = self::as2tz(self::_getTZFromSyncBlob(base64_decode($message->timezone))); + } + $event['recur_type'] = $message->recurrence->type == 6 ? calendar_rrule::YEARLY : + array_search($message->recurrence->type, self::$recur_type2as); + $event['recur_interval'] = $message->recurrence->interval; + + switch ($event['recur_type']) + { + case calendar_rrule::MONTHLY_WDAY: + // $message->recurrence->weekofmonth is not explicitly stored in egw, just taken from start date + // fall throught + case calendar_rrule::WEEKLY: + $event['recur_data'] = $message->recurrence->dayofweek; // 1=Su, 2=Mo, 4=Tu, .., 64=Sa + break; + case calendar_rrule::MONTHLY_MDAY: + // $message->recurrence->dayofmonth is not explicitly stored in egw, just taken from start date + break; + case calendar_rrule::YEARLY: + // $message->recurrence->(dayofmonth|monthofyear) is not explicitly stored in egw, just taken from start date + break; + } + if ($message->recurrence->until) + { + $event['recur_enddate'] = egw_time::server2user($message->recurrence->until); + } + $event['recur_exceptions'] = array(); + if ($message->exceptions) + { + foreach($message->exceptions as $exception) + { + $event['recur_exception'][] = egw_time::server2user($exception->exceptionstarttime); + } + $event['recur_exception'] = array_unique($event['recur_exception']); + } + if ($message->recurrence->occurrences > 0) + { + // calculate enddate from occurences count, as we only support enddate + $count = $message->recurrence->occurrences; + foreach(calendar_rrule::event2rrule($event, true) as $rtime) // true = timestamps are user time here, because of save! + { + if (--$count <= 0) break; + } + $event['recur_enddate'] = $rtime->format('ts'); + } + } + // only import alarms in own calendar + if ($message->reminder && $account == $GLOBALS['egw_info']['user']['account_id']) + { + foreach((array)$event['alarm'] as $alarm) + { + if (($alarm['all'] || $alarm['owner'] == $account) && $alarm['offset'] == 60*$message->reminder) + { + $alarm = true; // alarm already exists --> do nothing + break; + } + } + if ($alarm !== true) // new alarm + { + // delete all earlier alarms of that user + // user get's per AS only the earliest alarm, as AS only supports one alarm + // --> if a later alarm is returned, user probably modifed an existing alarm + foreach((array)$event['alarm'] as $key => $alarm) + { + if ($alarm['owner'] == $account && $alarm['offset'] > 60*$message->reminder) + { + unset($event['alarm'][$key]); + } + } + $event['alarm'][] = $alarm = array( + 'owner' => $account, + 'offset' => 60*$message->reminder, + ); + } + } + return $event; + } + + /** + * Creates or modifies a folder + * + * @param string $id of the parent folder + * @param string $oldid => if empty -> new folder created, else folder is to be renamed + * @param string $displayname => new folder name (to be created, or to be renamed to) + * @param string $type folder type, ignored in IMAP + * + * @return array|boolean stat array or false on error + */ + public function ChangeFolder($id, $oldid, $displayname, $type) + { + debugLog(__METHOD__."('$id', '$oldid', '$displayname', $type) NOT supported!"); + return false; + } + + /** + * Deletes (really delete) a Folder + * + * @param string $parentid of the folder to delete + * @param string $id of the folder to delete + * + * @return + * @TODO check what is to be returned + */ + public function DeleteFolder($parentid, $id) + { + debugLog(__METHOD__."('$parentid', '$id') NOT supported!"); + return false; + } + + /** + * Moves a message from one folder to another + * + * @param $folderid of the current folder + * @param $id of the message + * @param $newfolderid + * + * @return $newid as a string | boolean false on error + * + * After this call, StatMessage() and GetMessageList() should show the items + * to have a new parent. This means that it will disappear from GetMessageList() will not return the item + * at all on the source folder, and the destination folder will show the new message + */ + public function MoveMessage($folderid, $id, $newfolderid) + { + debugLog(__METHOD__."('$folderid', $id, '$newfolderid') NOT supported!"); + return false; + } + + /** + * Delete (really delete) a message in a folder + * + * @param $folderid + * @param $id + * + * @return boolean true on success, false on error, diffbackend does NOT use the returnvalue + * + * @DESC After this call has succeeded, a call to + * GetMessageList() should no longer list the message. If it does, the message will be re-sent to the PDA + * as it will be seen as a 'new' item. This means that if you don't implement this function, you will + * be able to delete messages on the PDA, but as soon as you sync, you'll get the item back + */ + public function DeleteMessage($folderid, $id) + { + if (!isset($this->caledar)) $this->calendar = new calendar_boupdate(); + + $ret = $this->calendar->delete($id); + debugLog(__METHOD__."('$folderid', $id) delete($id) returned ".array2string($ret)); + return $ret; + } + + /** + * This should change the 'read' flag of a message on disk. The $flags + * parameter can only be '1' (read) or '0' (unread). After a call to + * SetReadFlag(), GetMessageList() should return the message with the + * new 'flags' but should not modify the 'mod' parameter. If you do + * change 'mod', simply setting the message to 'read' on the PDA will trigger + * a full resync of the item from the server + */ + function SetReadFlag($folderid, $id, $flags) + { + debugLog(__METHOD__."('$folderid', $id, ".array2string($flags)." NOT supported!"); + return false; + } + + /** + * modify olflags (outlook style) flag of a message + * + * @param $folderid + * @param $id + * @param $flags + * + * @DESC The $flags parameter must contains the poommailflag Object + */ + function ChangeMessageFlag($folderid, $id, $flags) + { + debugLog(__METHOD__."('$folderid', $id, ".array2string($flags)." NOT supported!"); + return false; + } + + /** + * Get specified item from specified folder. + * + * Timezone wise we supply zpush with timestamps in servertime (!), which it "converts" in streamer::formatDate($ts) + * via gmstrftime("%Y%m%dT%H%M%SZ", $ts) to UTC times. + * Timezones are only used to get correct recurring events! + * + * @param string $folderid + * @param string|array $id cal_id or event array (used internally) + * @param int $truncsize + * @param int|bool $bodypreference=false + * @param $optionbodypreference=false + * @param int $mimesupport=0 + * @return SyncAppointment|boolean false on error + */ + public function GetMessage($folderid, $id, $truncsize, $bodypreference=false, $optionbodypreference=false, $mimesupport = 0) + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + debugLog (__METHOD__."('$folderid', ".array2string($id).", truncsize=$truncsize, bodyprefence=$bodypreference, mimesupport=$mimesupport)"); + $this->backend->splitID($folderid, $type, $account); + if (is_array($id)) + { + $event = $id; + $id = $event['id']; + } + else + { + list($id,$recur_date) = explode(':',$id); + if ($type != 'calendar' || !($event = $this->calendar->read($id,$recur_date,false,'server',$account))) + { + error_log(__METHOD__."('$folderid', $id, ...) read($id,null,false,'server',$account) returned false"); + return false; + } + } + debugLog(__METHOD__."($folderid,$id,...) start=$event[start]=".date('Y-m-d H:i:s',$event['start']).", recurrence=$event[recurrence]=".date('Y-m-d H:i:s',$event['recurrence'])); + foreach($event['recur_exception'] as $ex) debugLog("exception=$ex=".date('Y-m-d H:i:s',$ex)); + + $message = new SyncAppointment(); + + // set timezone + try { + $as_tz = self::tz2as($event['tzid']); + $message->timezone = base64_encode(self::_getSyncBlobFromTZ($as_tz)); + } + catch(Exception $e) { + // ignore exception, simply set no timezone, as it is optional + } + // copying timestamps (they are already read in servertime, so non tz conversation) + foreach(array( + 'start' => 'starttime', + 'end' => 'endtime', + 'created' => 'dtstamp', + 'modified' => 'dtstamp', + ) as $key => $attr) + { + if (!empty($event[$key])) $message->$attr = $event[$key]; + } + if (($message->alldayevent = (int)calendar_bo::isWholeDay($event))) + { + ++$message->endtime; // EGw all-day-events are 1 sec shorter! + } + // copying strings + foreach(array( + 'title' => 'subject', + 'uid' => 'uid', + 'location' => 'location', + ) as $key => $attr) + { + if (!empty($event[$key])) $message->$attr = $event[$key]; + } + + // appoint description + if ($bodypreference == false) + { + $message->body = $event['description']; + $message->bodysize = strlen($message->body); + $message->bodytruncated = 0; + } + else + { + if (strlen($event['description']) > 0) + { + debugLog("airsyncbasebody!"); + $message->airsyncbasebody = new SyncAirSyncBaseBody(); + $message->airsyncbasenativebodytype=1; + $this->backend->note2messagenote($event['description'], $bodypreference, $message->airsyncbasebody); + } + } + $message->md5body = md5($event['description']); + + $message->organizername = $GLOBALS['egw']->accounts->id2name($event['owner'],'account_fullname'); + $message->organizeremail = $GLOBALS['egw']->accounts->id2name($event['owner'],'account_email'); + + $message->sensitivity = $event['public'] ? 0 : 2; // 0=normal, 1=personal, 2=private, 3=confidential + + // busystatus=(0=free|1=tentative|2=busy|3=out-of-office), EGw has non_blocking=0|1 + $message->busystatus = $event['non_blocking'] ? 0 : 2; + + $message->attendees = array(); + foreach($event['participants'] as $uid => $status) + { + // AS does NOT want calendar owner as participant + if ($uid == $account) continue; + calendar_so::split_status($status, $quantity, $role); + + $attendee = new SyncAttendee(); + $attendee->attendeestatus = (int)self::$status2as[$status]; + $attendee->attendeetype = (int)self::$role2as[$role]; + if (is_numeric($uid)) + { + $attendee->name = $GLOBALS['egw']->accounts->id2name($uid,'account_fullname'); + $attendee->email = $GLOBALS['egw']->accounts->id2name($uid,'account_email'); + } + else + { + list($info) = $i = $this->calendar->resources[$uid[0]]['info'] ? + ExecMethod($this->calendar->resources[$uid[0]]['info'],substr($uid,1)) : array(false); + + if (!$info) continue; + + if (!$info['email'] && $info['responsible']) + { + $info['email'] = $GLOBALS['egw']->accounts->id2name($info['responsible'],'account_email'); + } + $attendee->name = empty($info['cn']) ? $info['name'] : $info['cn']; + $attendee->email = $info['email']; + + // external organizer: make him AS organizer, to get correct notifications + if ($role == 'CHAIR' && $uid[0] == 'e' && !empty($attendee->email)) + { + $message->organizername = $attendee->name; + $message->organizeremail = $attendee->email; + debugLog(__METHOD__."($folderid, $id, ...) external organizer detected (role=$role, uid=$uid), set as AS organizer: $message->organizername <$message->organizeremail>"); + } + if ($uid[0] == 'r') $attendee->type = 3; // 3 = resource + } + // email must NOT be empty, but MAY be an arbitrary text + if (empty($attendee->email)) $attendee->email = 'noreply-'.$uid.'-uid@egroupware.org'; + + $message->attendees[] = $attendee; + } + $message->categories = array(); + foreach($event['category'] ? explode(',',$event['category']) : array() as $cat_id) + { + $message->categories[] = categories::id2name($cat_id); + } + + // recurring information, only if not a single recurrence eg. virtual exception (!$recur_date) + if ($event['recur_type'] != calendar_rrule::NONE && !$recur_date) + { + $message->recurrence = $recurrence = new SyncRecurrence(); + $rrule = calendar_rrule::event2rrule($event,false); // false = timestamps in $event are servertime + $recurrence->type = (int)self::$recur_type2as[$rrule->type]; + $recurrence->interval = $rrule->interval; + switch ($rrule->type) + { + case calendar_rrule::MONTHLY_WDAY: + $recurrence->weekofmonth = $rrule->monthly_byday_num >= 1 ? + $rrule->monthly_byday_num : 5; // 1..5=last week of month, not -1 + // fall throught + case calendar_rrule::WEEKLY: + $recurrence->dayofweek = $rrule->weekdays; // 1=Su, 2=Mo, 4=Tu, .., 64=Sa + break; + case calendar_rrule::MONTHLY_MDAY: + $recurrence->dayofmonth = $rrule->monthly_bymonthday >= 1 ? // 1..31 + $rrule->monthly_bymonthday : 31; // not -1 for last day of month! + break; + case calendar_rrule::YEARLY: + $recurrence->dayofmonth = (int)$rrule->time->format('d'); // 1..31 + $recurrence->monthofyear = (int)$rrule->time->format('m'); // 1..12 + break; + } + if ($rrule->enddate) // enddate is only a date, but AS needs a time incl. correct starttime! + { + $enddate = clone $rrule->time; + $enddate->setDate($rrule->enddate->format('Y'), $rrule->enddate->format('m'), + $rrule->enddate->format('d')); + $recurrence->until = $enddate->format('server'); + } + + if ($rrule->exceptions) + { + // search real / non-virtual exceptions + if (!empty($event['uid'])) + { + $ex_events =& $this->calendar->search(array( + 'query' => array('cal_uid' => $event['uid']), + 'filter' => 'owner', // return all possible entries + 'daywise' => false, + 'date_format' => 'server', + )); + } + else + { + debugLog(__METHOD__.__LINE__." Exceptions found but no UID given for Event:".$event['id'].' Exceptions:'.array2string($event['recur_exception'])); + } + if (count($ex_events)>=1) debugLog(__METHOD__.__LINE__." found ".count($ex_events)." exeptions for event with UID/ID:".$event['uid'].'/'.$event['id']); + + $message->exceptions = array(); + foreach($ex_events as $ex_event) + { + if ($ex_event['id'] == $event['id']) continue; // ignore series master + $exception = $this->GetMessage($folderid, $ex_event, $truncsize, $bodypreference, $mimesupport); + $exception->exceptionstarttime = $exception_time = $ex_event['recurrence']; + foreach(array('attendees','recurrence','uid','timezone','organizername','organizeremail') as $not_supported) + { + $exception->$not_supported = null; // not allowed in exceptions :-( + } + $exception->deleted = 0; + if (($key = array_search($exception_time,$event['recur_exception'])) !== false) + { + unset($event['recur_exception'][$key]); + } + debugLog(__METHOD__."() added exception ".date('Y-m-d H:i:s',$exception_time).' '.array2string($exception)); + $message->exceptions[] = $exception; + } + // add rest of exceptions as deleted + foreach($event['recur_exception'] as $exception_time) + { + if (!empty($exception_time)) + { + if (empty($event['uid'])) debugLog(__METHOD__.__LINE__." BEWARE no UID given for this event:".$event['id'].' but exception is set for '.$exception_time); + $exception = new SyncAppointment(); // exceptions seems to be full SyncAppointments, with only starttime required + $exception->deleted = 1; + $exception->exceptionstarttime = $exception_time; + debugLog(__METHOD__."() added deleted exception ".date('Y-m-d H:i:s',$exception_time).' '.array2string($exception)); + $message->exceptions[] = $exception; + } + } + } + /* disabled virtual exceptions for now, as AS does NOT support changed participants or status + // add virtual exceptions here too (get_recurrence_exceptions should be able to return real-exceptions too!) + foreach($this->calendar->so->get_recurrence_exceptions($event, + egw_time::$server_timezone->getName(), $cutoffdate, 0, 'all') as $exception_time) + { + // exceptions seems to be full SyncAppointments, with only exceptionstarttime required + $exception = $this->GetMessage($folderid, $event['id'].':'.$exception_time, $truncsize, $bodypreference, $mimesupport); + $exception->deleted = 0; + $exception->exceptionstarttime = $exception_time; + debugLog(__METHOD__."() added virtual exception ".date('Y-m-d H:i:s',$exception_time).' '.array2string($exception)); + $message->exceptions[] = $exception; + }*/ + //debugLog(__METHOD__."($id) message->exceptions=".array2string($message->exceptions)); + } + // only return alarms if in own calendar + if ($account == $GLOBALS['egw_info']['user']['account_id'] && $event['alarm']) + { + foreach($event['alarm'] as $alarm) + { + if ($alarm['all'] || $alarm['owner'] == $account) + { + $message->reminder = $alarm['offset']/60; // is in minutes, not seconds as in EGw + break; // AS supports only one alarm! (we use the next/earliest one) + } + } + } + //$message->meetingstatus; + + return $message; + } + + /** + * StatMessage should return message stats, analogous to the folder stats (StatFolder). Entries are: + * 'id' => Server unique identifier for the message. Again, try to keep this short (under 20 chars) + * 'flags' => simply '0' for unread, '1' for read + * 'mod' => modification signature. As soon as this signature changes, the item is assumed to be completely + * changed, and will be sent to the PDA as a whole. Normally you can use something like the modification + * time for this field, which will change as soon as the contents have changed. + * + * @param string $folderid + * @param int|array $id event id or array or cal_id:recur_date for virtual exception + * @return array + */ + public function StatMessage($folderid, $id) + { + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + + if (!($etag = $this->calendar->get_etag($id, $nul, true, true))) // last true: $only_master=true + { + $stat = false; + // error_log why access is denied (should never happen for everything returned by calendar_bo::search) + $backup = $this->calendar->debug; + //$this->calendar->debug = 2; + list($id) = explode(':',$id); + $this->calendar->check_perms(EGW_ACL_FREEBUSY, $id, 0, 'server'); + $this->calendar->debug = $backup; + } + else + { + $stat = array( + 'mod' => $etag, + 'id' => is_array($id) ? $id['id'] : $id, + 'flags' => 1, + ); + } + //debugLog (__METHOD__."('$folderid',".array2string(is_array($id) ? $id['id'] : $id).") returning ".array2string($stat)); + + return $stat; + } + + /** + * Return a changes array + * + * if changes occurr default diff engine computes the actual changes + * + * @param string $folderid + * @param string &$syncstate on call old syncstate, on return new syncstate + * @return array|boolean false if $folderid not found, array() if no changes or array(array("type" => "fakeChange")) + */ + function AlterPingChanges($folderid, &$syncstate) + { + $this->backend->splitID($folderid, $type, $owner); + debugLog(__METHOD__."('$folderid','$syncstate') type='$type', owner=$owner"); + + if ($type != 'calendar') return false; + + if (!isset($this->calendar)) $this->calendar = new calendar_boupdate(); + //$ctag = $this->calendar->get_ctag($owner,'owner',true); // true only consider recurrence master + $ctag = $this->calendar->get_ctag($owner,false,true); // we only want to fetch the owners events, where he is a participant too + // workaround for syncstate = 0 when calendar is empty causes synctate to not return 0 but array resulting in foldersync loop + if ($ctag == 0) $ctag = 1; + $changes = array(); // no change + $syncstate_was = $syncstate; + + if ($ctag !== $syncstate) + { + $syncstate = $ctag; + $changes = array(array('type' => 'fakeChange')); + } + //error_log(__METHOD__."('$folderid','$syncstate_was') syncstate='$syncstate' returning ".array2string($changes)); + debugLog(__METHOD__."('$folderid','$syncstate_was') syncstate='$syncstate' returning ".array2string($changes)); + return $changes; + } + + /** + * Return AS timezone data from given timezone and time + * + * AS spezifies the timezone by the date it changes to dst and back and the offsets. + * Unfortunately this data is not available from PHP's DateTime(Zone) class. + * Just given the exact time of the next transition, which is available via DateTimeZone::getTransistions(), + * will fail for recurring events longer then a year, as the transition date/time changes! + * + * We use now the RRule given in the iCal timezone defintion available via calendar_timezones::tz2id($tz,'component'). + * + * Not every timezone uses DST, in which case only bias matters and dstbias=0 + * (probably all other values should be 0, as MapiMapping::_getGMTTZ() in backend/ics.php does it). + * + * For southern hermisphere DST in southern winter (eg. January), Active Sync implementation of iPhone + * uses a negative dstbias (eg. -60) and an accordingly moved start- and end-time. + * For Pacific/Auckland TZ iPhone AS implementation uses -720=-12h instead of 720=+12h. + * Both are corrected now in our Active Sync timezone generation, as we can not find + * matching timezones for incomming timezone data. iPhone seems not to care on receiving about the above. + * + * @param string|DateTimeZone $tz timezone, timezone name (eg. "Europe/Berlin") or ical with VTIMEZONE + * @param int|string|DateTime $ts=null time for which active sync timezone data is requested, default current time + * @return array with values for keys: + * - "bias": timezone offset from UTC in minutes for NO DST + * - "dstendmonth", "dstendday", "dstendweek", "dstendhour", "dstendminute", "dstendsecond", "dstendmillis" + * - "stdbias": seems not to be used + * - "dststartmonth", "dststartday", "dststartweek", "dststarthour", "dststartminute", "dststartsecond", "dststartmillis" + * - "dstbias": offset in minutes for no DST --> DST, usually 60 or 0 for no DST + * + * @link http://download.microsoft.com/download/5/D/D/5DD33FDF-91F5-496D-9884-0A0B0EE698BB/%5BMS-ASDTYPE%5D.pdf + * @throws egw_exception_assertion_failed if no vtimezone data found for given timezone + */ + static public function tz2as($tz,$ts=null) + { +/* +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +--> bias: -60 min +TZOFFSETTO:+0200 +--> dstbias: +1000 - +0200 = +0100 = -60 min +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +--> dststart: month: 3, day: SU(0???), week: -1|5, hour: 2, minute, second, millis: 0 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +--> dstend: month: 10, day: SU(0???), week: -1|5, hour: 3, minute, second, millis: 0 +END:STANDARD +END:VTIMEZONE +*/ + $data = array( + 'bias' => 0, + 'stdbias' => 0, + 'dstbias' => 0, + 'dststartyear' => 0, 'dststartmonth' => 0, 'dststartday' => 0, 'dststartweek' => 0, + 'dststarthour' => 0, 'dststartminute' => 0, 'dststartsecond' => 0, 'dststartmillis' => 0, + 'dstendyear' => 0, 'dstendmonth' => 0, 'dstendday' => 0, 'dstendweek' => 0, + 'dstendhour' => 0, 'dstendminute' => 0, 'dstendsecond' => 0, 'dstendmillis' => 0, + ); + + $name = $component = is_a($tz,'DateTimeZone') ? $tz->getName() : $tz; + if (strpos($component, 'VTIMEZONE') === false) $component = calendar_timezones::tz2id($name,'component'); + // parse ical timezone defintion + $ical = self::ical2array($ical=$component); + $standard = $ical['VTIMEZONE']['STANDARD']; + $daylight = $ical['VTIMEZONE']['DAYLIGHT']; + + if (!isset($standard)) + { + if (preg_match('/^etc\/gmt([+-])([0-9]+)$/i',$name,$matches)) + { + $standard = array( + 'TZOFFSETTO' => sprintf('%s%02d00',$matches[1],$matches[2]), + 'TZOFFSETFROM' => sprintf('%s%02d00',$matches[1],$matches[2]), + ); + unset($daylight); + } + else + { + throw new egw_exception_assertion_failed("NO standard component for '$name' in '$component'!"); + } + } + // get bias and dstbias from standard component, which is present in all tz's + // (dstbias is relative to bias and almost always 60 or 0) + $data['bias'] = -(60 * substr($standard['TZOFFSETTO'],0,-2) + substr($standard['TZOFFSETTO'],-2)); + $data['dstbias'] = -(60 * substr($standard['TZOFFSETFROM'],0,-2) + substr($standard['TZOFFSETFROM'],-2) + $data['bias']); + + // check if we have an additional DAYLIGHT component and both have a RRULE component --> tz uses daylight saving + if (isset($standard['RRULE']) && isset($daylight) && isset($daylight['RRULE'])) + { + foreach(array('dststart' => $daylight,'dstend' => $standard) as $prefix => $comp) + { + if (preg_match('/FREQ=YEARLY;BYDAY=(.*);BYMONTH=(\d+)/',$comp['RRULE'],$matches)) + { + $data[$prefix.'month'] = (int)$matches[2]; + $data[$prefix.'week'] = (int)$matches[1]; + // -1 for last week might be 5 for as as in recuring events definition + // seems for start 1SU is always returned with week=5, like -1SU + if ($data[$prefix.'week'] < 0 || $prefix == 'dststart' && $matches[1] == '1SU') + { + $data[$prefix.'week'] = 5; + } + // if both start and end use 1SU use week=5 and decrement month + if ($prefix == 'dststart') $start_byday = $matches[1]; + if ($prefix == 'dstend' && $matches[1] == '1SU' && $start_byday == '1SU') + { + $data[$prefix.'week'] = 5; + if ($prefix == 'dstend') $data[$prefix.'month'] -= 1; + } + static $day2int = array('SU'=>0,'MO'=>1,'TU'=>2,'WE'=>3,'TH'=>4,'FR'=>5,'SA'=>6); + $data[$prefix.'day'] = (int)$day2int[substr($matches[1],-2)]; + } + if (preg_match('/^\d{8}T(\d{6})$/',$comp['DTSTART'],$matches)) + { + $data[$prefix.'hour'] = (int)substr($matches[1],0,2)+($prefix=='dststart'?-1:1)*$data['dstbias']/60; + $data[$prefix.'minute'] = (int)substr($matches[1],2,2)+($prefix=='dststart'?-1:1)*$data['dstbias']%60; + $data[$prefix.'second'] = (int)substr($matches[1],4,2); + } + } + // for southern hermisphere, were DST is in January, we have to swap start- and end-hour/-minute + if ($data['dststartmonth'] > $data['dstendmonth']) + { + $start = $data['dststarthour']; $data['dststarthour'] = $data['dstendhour']; $data['dstendhour'] = $start; + $start = $data['dststartminute']; $data['dststartminute'] = $data['dstendminute']; $data['dstendminute'] = $start; + } + } + //error_log(__METHOD__."('$name') returning ".array2string($data)); + return $data; + } + + /** + * Simple iCal parser: + * + * BEGIN:VTIMEZONE + * TZID:Europe/Berlin + * X-LIC-LOCATION:Europe/Berlin + * BEGIN:DAYLIGHT + * TZOFFSETFROM:+0100 + * TZOFFSETTO:+0200 + * TZNAME:CEST + * DTSTART:19700329T020000 + * RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 + * END:DAYLIGHT + * BEGIN:STANDARD + * TZOFFSETFROM:+0200 + * TZOFFSETTO:+0100 + * TZNAME:CET + * DTSTART:19701025T030000 + * RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + * END:STANDARD + * END:VTIMEZONE + * + * Array + * ( + * [VTIMEZONE] => Array + * ( + * [TZID] => Europe/Berlin + * [X-LIC-LOCATION] => Europe/Berlin + * [DAYLIGHT] => Array + * ( + * [TZOFFSETFROM] => +0100 + * [TZOFFSETTO] => +0200 + * [TZNAME] => CEST + * [DTSTART] => 19700329T020000 + * [RRULE] => FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 + * ) + * [STANDARD] => Array + * ( + * [TZOFFSETFROM] => +0200 + * [TZOFFSETTO] => +0100 + * [TZNAME] => CET + * [DTSTART] => 19701025T030000 + * [RRULE] => FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 + * ) + * ) + * ) + * + * @param string|array $ical lines of ical file + * @param string $component=null + * @return array with parsed ical components + */ + static public function ical2array(&$ical,$component=null) + { + $arr = array(); + if (!is_array($ical)) $ical = preg_split("/[\r\n]+/m", $ical); + while (($line = array_shift($ical))) + { + list($name,$value) = explode(':',$line,2); + if ($name == 'BEGIN') + { + $arr[$value] = self::ical2array($ical,$value); + } + elseif($name == 'END') + { + break; + } + else + { + $arr[$name] = $value; + } + } + return $arr; + } + + /** + * Get timezone from AS timezone data + * + * Here we can only loop through all available timezones (starting with the users timezone) and + * try to find a timezone matching the change data and offsets specified in $data. + * This conversation is not unique, as multiple timezones can match the given data or none! + * + * @param array $data + * @return string timezone name, eg. "Europe/Berlin" or 'UTC' if NO matching timezone found + */ + public static function as2tz(array $data) + { + static $cache; // some caching withing the request + + unset($data['name']); // not used, but can stall the match + + $key = serialize($data); + + for($n = 0; !isset($cache[$key]); ++$n) + { + if (!$n) // check users timezone first + { + $tz = egw_time::$user_timezone->getName(); + } + elseif (!($tz = calendar_timezones::id2tz($n))) // no further timezones to check + { + $cache[$key] = 'UTC'; + error_log(__METHOD__.'('.array2string($data).') NO matching timezone found --> using UTC now!'); + break; + } + try { + if (self::tz2as($tz) == $data) + { + $cache[$key] = $tz; + break; + } + } + catch(Exception $e) { + // simpy ignore that, as it only means $tz can NOT be converted, because it has no VTIMEZONE component + } + } + return $cache[$key]; + } + + /** + * Unpack timezone info from Sync + * + * copied from backend/ics.php + */ + static public function _getTZFromSyncBlob($data) + { + $tz = unpack( "lbias/a64name/vdstendyear/vdstendmonth/vdstendday/vdstendweek/vdstendhour/vdstendminute/vdstendsecond/vdstendmillis/" . + "lstdbias/a64name/vdststartyear/vdststartmonth/vdststartday/vdststartweek/vdststarthour/vdststartminute/vdststartsecond/vdststartmillis/" . + "ldstbias", $data); + + return $tz; + } + + /** + * Pack timezone info for Sync + * + * copied from backend/ics.php + */ + static public function _getSyncBlobFromTZ($tz) + { + $packed = pack("la64vvvvvvvv" . "la64vvvvvvvv" . "l", + $tz["bias"], "", 0, $tz["dstendmonth"], $tz["dstendday"], $tz["dstendweek"], $tz["dstendhour"], $tz["dstendminute"], $tz["dstendsecond"], $tz["dstendmillis"], + $tz["stdbias"], "", 0, $tz["dststartmonth"], $tz["dststartday"], $tz["dststartweek"], $tz["dststarthour"], $tz["dststartminute"], $tz["dststartsecond"], $tz["dststartmillis"], + $tz["dstbias"]); + + return $packed; + } + + /** + * Populates $settings for the preferences + * + * @param array|string $hook_data + * @return array + */ + function egw_settings($hook_data) + { + $cals = array(); + if (!$hook_data['setup'] && in_array($hook_data['type'], array('user', 'group'))) + { + foreach (calendar_bo::list_calendars($hook_data['account_id']) as $entry) + { + $account_id = $entry['grantor']; + $cals[$account_id] = $entry['name']; + } + if ($hook_data['account_id'] > 0) unset($cals[$hook_data['account_id']]); // skip current user + } + $cals['G'] = lang('Primary group'); + $cals['A'] = lang('All'); + // allow to force "none", to not show the prefs to the users + if ($GLOBALS['type'] == 'forced') + { + $cals['N'] = lang('None'); + } + $settings['calendar-cals'] = array( + 'type' => 'multiselect', + 'label' => 'Additional calendars to sync', + 'help' => 'Not all devices support additonal calendars. Your personal calendar is always synchronised.', + 'name' => 'calendar-cals', + 'values' => $cals, + 'xmlrpc' => True, + 'admin' => False, + ); + + return $settings; + } +} + +/** + * Testcode for active sync timezone stuff + * + * You need to comment implements activesync_plugin_write + */ +if (isset($_SERVER['SCRIPT_FILENAME']) && $_SERVER['SCRIPT_FILENAME'] == __FILE__) // some tests +{ + $GLOBALS['egw_info'] = array( + 'flags' => array( + 'currentapp' => 'login' + ) + ); + require_once('../../header.inc.php'); + ini_set('display_errors',1); + error_reporting(E_ALL & ~E_NOTICE); + + echo "Conversation of ActiveSync Timezone Blobs to TZID's\n\n"; + echo "

Conversation of ActiveSync Timezone Blobs to TZID's

\n"; + echo "\n\n"; + echo "\n"; + echo "\n"; + + // TZID => AS timezone blobs reported by various devices + foreach(array( + 'Europe/Berlin' => 'xP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAFAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAFAAMAAAAAAAAAxP///w==', + 'Europe/Helsinki' => 'iP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAFAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAFAAQAAAAAAAAAxP///w==', + 'Asia/Tokyo' => '5P3//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxP///w==', + 'Atlantic/Azores' => 'PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAFAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAFAAIAAAAAAAAAxP///w==', + 'America/Los_Angeles' => '4AEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAABAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAACAAMAAAAAAAAAxP///w==', + 'America/New_York' => 'LAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAABAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAACAAMAAAAAAAAAxP///w==', + 'Pacific/Auckland' => 'MP3//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAABAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAFAAMAAAAAAAAAxP///w==', + 'Australia/Sydney' => 'qP3//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAFAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAFAAIAAAAAAAAAxP///w==', + 'Etc/GMT+3' => 'TP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', + ) as $tz => $sync_blob) + { + // get as timezone data for a given timezone + $ical = calendar_timezones::tz2id($tz,'component'); + //echo "
".print_r($ical,true)."
\n"; + $ical_arr = calendar_activesync::ical2array($ical_tz=$ical); + //echo "
".print_r($ical_arr,true)."
\n"; + $as_tz = calendar_activesync::tz2as($tz); + //echo "$tz=
".print_r($as_tz,true)."
\n"; + + $as_tz_org = calendar_activesync::_getTZFromSyncBlob(base64_decode($sync_blob)); + //echo "sync_blob=
".print_r($as_tz_org,true)."
\n"; + + // find matching timezone from as data + // this returns the FIRST match, which is in case of Pacific/Auckland eg. Antarctica/McMurdo ;-) + $matched = calendar_activesync::as2tz($as_tz); + //echo array2string($matched); + + echo "\n"; + foreach(array('dststart','dstend') as $prefix) + { + echo "\n"; + } + echo "\n"; + } + echo "
TZIDbiasdstbiasdststartdstendmatched TZID
$tz
$ical
$as_tz_org[bias]
$as_tz[bias]
$as_tz_org[dstbias]
$as_tz[dstbias]
\n"; + foreach(array($as_tz_org,$as_tz) as $n => $arr) + { + $parts = array(); + foreach(array('year','month','day','week','hour','minute','second') as $postfix) + { + $failed = $n && $as_tz_org[$prefix.$postfix] !== $as_tz[$prefix.$postfix]; + $parts[] = ($failed?'':'').$arr[$prefix.$postfix].($failed?'':''); + } + echo implode(' ', $parts).(!$n?'
':''); + } + echo "
 
".($matched=='UTC'?'':'').$matched.($matched=='UTC'?'':'')."
\n"; + echo "\n"; +} diff --git a/infolog/inc/class.infolog_zpush.inc.php b/infolog/inc/class.infolog_zpush.inc.php new file mode 100644 index 0000000000..1f35a861eb --- /dev/null +++ b/infolog/inc/class.infolog_zpush.inc.php @@ -0,0 +1,573 @@ + + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @version $Id$ + */ + +/** + * InfoLog activesync plugin + */ +class infolog_activesync implements activesync_plugin_write +{ + /** + * @var BackendEGW + */ + private $backend; + + /** + * Instance of infolog_bo + * + * @var infolog_bo + */ + private $infolog; + + /** + * Mapping of ActiveSync SyncContact attributes to EGroupware InfoLog array-keys + * + * @var array + */ + static public $mapping = array( + 'body' => 'info_des', + 'categories' => 'info_cat', // infolog supports only a single category + 'complete' => 'info_status', // 0 or 1 <--> 'done', .... + 'datecompleted' => 'info_datecompleted', + 'duedate' => 'info_enddate', + 'importance' => 'info_priority', // 0=Low, 1=Normal, 2=High (EGW additional 3=Urgent) + 'sensitivity' => 'info_access', // 0=Normal, 1=Personal, 2=Private, 3=Confiential <--> 'public', 'private' + 'startdate' => 'info_startdate', + 'subject' => 'info_subject', + //'recurrence' => EGroupware InfoLog does NOT support recuring tasks + //'reminderset'/'remindertime' => EGroupware InfoLog does NOT support (custom) alarms + //'utcduedate'/'utcstartdate' what's the difference to startdate/duedate? + ); + + /** + * Following status gets mapped to boolean AS completed + * + * @var array + */ + static public $done_status = array('done', 'billed'); + + /** + * Constructor + * + * @param BackendEGW $backend + */ + public function __construct(BackendEGW $backend) + { + $this->backend = $backend; + } + + /** + * Get infolog(s) folder + * + * Currently we only return an own infolog + * + * @param int $account=null account_id of addressbook or null to get array of all addressbooks + * @return string|array folder name of array with int account_id => folder name pairs + */ + private function get_folders($account=null) + { + $folders = array( + $GLOBALS['egw_info']['user']['account_id'] => lang('InfoLog'), + ); + return $account ? $folders[$account] : $folders; + } + + + /** + * This function is analogous to GetMessageList. + * + * @ToDo implement preference, include own private calendar + */ + public function GetFolderList() + { + $folderlist = array(); + foreach ($this->get_folders() as $account => $label) + { + $folderlist[] = array( + 'id' => $this->backend->createID('infolog',$account), + 'mod' => $label, + 'parent'=> '0', + ); + } + //debugLog(__METHOD__."() returning ".array2string($folderlist)); + return $folderlist; + } + + /** + * Get Information about a folder + * + * @param string $id + * @return SyncFolder|boolean false on error + */ + public function GetFolder($id) + { + $this->backend->splitID($id, $type, $owner); + + $folderObj = new SyncFolder(); + $folderObj->serverid = $id; + $folderObj->parentid = '0'; + $folderObj->displayname = $this->get_folders($owner); + + if ($owner == $GLOBALS['egw_info']['user']['account_id']) + { + $folderObj->type = SYNC_FOLDER_TYPE_TASK; + } + else + { + $folderObj->type = SYNC_FOLDER_TYPE_USER_TASK; + } +/* + // not existing folder requested --> return false + if (is_null($folderObj->displayname)) + { + $folderObj = false; + debugLog(__METHOD__."($id) returning ".array2string($folderObj)); + } +*/ + //debugLog(__METHOD__."('$id') returning ".array2string($folderObj)); + return $folderObj; + } + + /** + * Return folder stats. This means you must return an associative array with the + * following properties: + * + * "id" => The server ID that will be used to identify the folder. It must be unique, and not too long + * How long exactly is not known, but try keeping it under 20 chars or so. It must be a string. + * "parent" => The server ID of the parent of the folder. Same restrictions as 'id' apply. + * "mod" => This is the modification signature. It is any arbitrary string which is constant as long as + * the folder has not changed. In practice this means that 'mod' can be equal to the folder name + * as this is the only thing that ever changes in folders. (the type is normally constant) + * + * @return array with values for keys 'id', 'mod' and 'parent' + */ + public function StatFolder($id) + { + $this->backend->splitID($id, $type, $owner); + + $stat = array( + 'id' => $id, + 'mod' => $this->get_folders($owner), + 'parent' => '0', + ); +/* + // not existing folder requested --> return false + if (is_null($stat['mod'])) + { + $stat = false; + debugLog(__METHOD__."('$id') ".function_backtrace()); + } +*/ + //error_log(__METHOD__."('$id') returning ".array2string($stat)); + debugLog(__METHOD__."('$id') returning ".array2string($stat)); + return $stat; + } + + /** + * Should return a list (array) of messages, each entry being an associative array + * with the same entries as StatMessage(). This function should return stable information; ie + * if nothing has changed, the items in the array must be exactly the same. The order of + * the items within the array is not important though. + * + * The cutoffdate is a date in the past, representing the date since which items should be shown. + * This cutoffdate is determined by the user's setting of getting 'Last 3 days' of e-mail, etc. If + * you ignore the cutoffdate, the user will not be able to select their own cutoffdate, but all + * will work OK apart from that. + * + * @param string $id folder id + * @param int $cutoffdate=null + * @return array + */ + function GetMessageList($id, $cutoffdate=NULL) + { + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + $this->backend->splitID($id,$type,$user); + if (!($infolog_types = $GLOBALS['egw_info']['user']['preferences']['activesync']['infolog-types'])) + { + $infolog_types = 'task'; + } + $filter = array( + 'filter' => $user == $GLOBALS['egw_info']['user']['account_id'] ? 'own' : 'user'.$user, + 'col_filter' => array('info_type' => explode(',', $infolog_types)), + 'date_format' => 'server', + ); + + $messagelist = array(); + if (($infologs =& $this->infolog->search($filter))) + { + foreach($infologs as $infolog) + { + $messagelist[] = $this->StatMessage($id, $infolog); + } + } + //error_log(__METHOD__."('$id', $cutoffdate) filter=".array2string($filter)." returning ".count($messagelist).' entries'); + return $messagelist; + } + + /** + * Get specified item from specified folder. + * + * @param string $folderid + * @param string $id + * @param int $truncsize + * @param int $bodypreference + * @param bool $mimesupport + * @return $messageobject|boolean false on error + */ + public function GetMessage($folderid, $id, $truncsize, $bodypreference=false, $optionbodypreference=false, $mimesupport = 0) + { + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + debugLog (__METHOD__."('$folderid', $id, truncsize=$truncsize, bodyprefence=$bodypreference, mimesupport=$mimesupport)"); + $this->backend->splitID($folderid, $type, $account); + if ($type != 'infolog' || !($infolog = $this->infolog->read($id, true, 'server'))) + { + error_log(__METHOD__."('$folderid',$id,...) Folder wrong (type=$type, account=$account) or contact not existing (read($id)=".array2string($infolog).")! returning false"); + return false; + } + $message = new SyncTask(); + foreach(self::$mapping as $key => $attr) + { + switch ($attr) + { + case 'info_des': + if ($bodypreference == false) + { + $message->body = $infolog[$attr]; + $message->bodysize = strlen($message->body); + $message->bodytruncated = 0; + } + else + { + if (strlen ($infolog[$attr]) > 0) + { + debugLog("airsyncbasebody!"); + $message->airsyncbasebody = new SyncAirSyncBaseBody(); + $message->airsyncbasenativebodytype=1; + $this->backend->note2messagenote($infolog[$attr], $bodypreference, $message->airsyncbasebody); + } + } + $message->md5body = md5($infolog[$attr]); + break; + + case 'info_cat': + $message->$key = array(); + foreach($infolog[$attr] ? explode(',',$infolog[$attr]) : array() as $cat_id) + { + $message->categories[] = categories::id2name($cat_id); + } + break; + + case 'info_access': // 0=Normal, 1=Personal, 2=Private, 3=Confiential <--> 'public', 'private' + $message->$key = $infolog[$attr] == 'private' ? 2 : 0; + break; + + case 'info_status': // 0 or 1 <--> 'done', .... + $message->key = (int)(in_array($infolog[$attr], self::$done_status)); + break; + + case 'info_priority': + if ($infolog[$attr] > 2) $infolog[$attr] = 2; // AS does not know 3=Urgent (only 0=Low, 1=Normal, 2=High) + // fall through + default: + if (!empty($infolog[$attr])) $message->$key = $infolog[$attr]; + } + } + //debugLog(__METHOD__."(folder='$folderid',$id,...) returning ".array2string($message)); + return $message; + } + + /** + * StatMessage should return message stats, analogous to the folder stats (StatFolder). Entries are: + * 'id' => Server unique identifier for the message. Again, try to keep this short (under 20 chars) + * 'flags' => simply '0' for unread, '1' for read + * 'mod' => modification signature. As soon as this signature changes, the item is assumed to be completely + * changed, and will be sent to the PDA as a whole. Normally you can use something like the modification + * time for this field, which will change as soon as the contents have changed. + * + * @param string $folderid + * @param int|array $infolog info_id or array with data + * @return array + */ + public function StatMessage($folderid, $infolog) + { + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + if (!is_array($infolog)) $infolog = $this->infolog->read($infolog, true, 'server'); + + if (!$infolog) + { + $stat = false; + } + else + { + $stat = array( + 'mod' => $infolog['info_datemodified'], + 'id' => $infolog['info_id'], + 'flags' => 1, + ); + } + //debugLog (__METHOD__."('$folderid',".array2string($id).") returning ".array2string($stat)); + //error_log(__METHOD__."('$folderid',$infolog) returning ".array2string($stat)); + return $stat; + } + + /** + * Creates or modifies a folder + * + * @param $id of the parent folder + * @param $oldid => if empty -> new folder created, else folder is to be renamed + * @param $displayname => new folder name (to be created, or to be renamed to) + * @param type => folder type, ignored in IMAP + * + * @return stat | boolean false on error + * + */ + public function ChangeFolder($id, $oldid, $displayname, $type) + { + debugLog(__METHOD__." not implemented"); + } + + /** + * Deletes (really delete) a Folder + * + * @param $parentid of the folder to delete + * @param $id of the folder to delete + * + * @return + * @TODO check what is to be returned + * + */ + public function DeleteFolder($parentid, $id) + { + debugLog(__METHOD__." not implemented"); + } + + /** + * Changes or adds a message on the server + * + * @param string $folderid + * @param int $id for change | empty for create new + * @param SyncContact $message object to SyncObject to create + * + * @return array $stat whatever would be returned from StatMessage + * + * This function is called when a message has been changed on the PDA. You should parse the new + * message here and save the changes to disk. The return value must be whatever would be returned + * from StatMessage() after the message has been saved. This means that both the 'flags' and the 'mod' + * properties of the StatMessage() item may change via ChangeMessage(). + * Note that this function will never be called on E-mail items as you can't change e-mail items, you + * can only set them as 'read'. + */ + public function ChangeMessage($folderid, $id, $message) + { + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + $this->backend->splitID($folderid, $type, $account); + //debugLog(__METHOD__. " Id " .$id. " Account ". $account . " FolderID " . $folderid); + if ($type != 'infolog') // || !($infolog = $this->addressbook->read($id))) + { + debugLog(__METHOD__." Folder wrong or infolog not existing"); + return false; + } + $infolog = array(); + if (empty($id) && $this->infolog->check_access(0, EGW_ACL_EDIT, $account) || + ($infolog = $this->infolog->read($id)) && $this->infolog->check_access($infolog, EGW_ACL_EDIT)) + { + if (!$infolog) $infolog = array(); + foreach (self::$mapping as $key => $attr) + { + switch ($attr) + { + case 'info_des': + $infolog[$attr] = $this->backend->messagenote2note($message->body, $message->rtf, $message->airsyncbasebody); + break; + + case 'info_cat': + if (is_array($message->$key)) + { + $infolog[$attr] = implode(',', array_filter($this->infolog->find_or_add_categories($message->$key, $id),'strlen')); + } + break; + + case 'info_access': // 0=Normal, 1=Personal, 2=Private, 3=Confiential <--> 'public', 'private' + $infolog[$attr] = $message->$key ? 'public' : 'private'; + break; + + case 'info_status': // 0 or 1 in AS --> do NOT change infolog status, if it maps to identical completed boolean value + if ((in_array($infolog[$attr], self::$done_status) !== (boolean)$message->$key) || !isset($infolog[$attr])) + { + $infolog[$attr] = $message->$key ? 'done' : 'not-started'; + if (!(boolean)$message->$key) $infolog['info_percent'] = 0; + } + break; + + case 'info_priority': // AS does not know 3=Urgent (only 0=Low, 1=Normal, 2=High) + if ($infolog[$attr] == 3 && $message->$key == 2) break; // --> do NOT change Urgent, if AS reports High + // fall through + default: + $infolog[$attr] = $message->$key; + break; + } + } + // $infolog['info_owner'] = $account; + if (!empty($id)) $infolog['info_id'] = $id; + $newid = $this->infolog->write($infolog); + debugLog(__METHOD__."($folderid,$id) infolog(".array2string($infolog).") returning ".array2string($newid)); + return $this->StatMessage($folderid, $newid); + } + return false; + } + + /** + * Moves a message from one folder to another + * + * @param $folderid of the current folder + * @param $id of the message + * @param $newfolderid + * + * @return $newid as a string | boolean false on error + * + * After this call, StatMessage() and GetMessageList() should show the items + * to have a new parent. This means that it will disappear from GetMessageList() will not return the item + * at all on the source folder, and the destination folder will show the new message + * + * @ToDo: If this gets implemented, we have to take into account the 'addressbook-all-in-one' pref! + */ + public function MoveMessage($folderid, $id, $newfolderid) + { + debugLog(__METHOD__."('$folderid', $id, $newfolderid) NOT implemented --> returning false"); + return false; + } + + + /** + * Delete (really delete) a message in a folder + * + * @param $folderid + * @param $id + * + * @return boolean true on success, false on error, diffbackend does NOT use the returnvalue + * + * @DESC After this call has succeeded, a call to + * GetMessageList() should no longer list the message. If it does, the message will be re-sent to the PDA + * as it will be seen as a 'new' item. This means that if you don't implement this function, you will + * be able to delete messages on the PDA, but as soon as you sync, you'll get the item back + */ + public function DeleteMessage($folderid, $id) + { + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + $ret = $this->infolog->delete($id); + debugLog(__METHOD__."('$folderid', $id) delete($id) returned ".array2string($ret)); + return $ret; + } + + /** + * This should change the 'read' flag of a message on disk. The $flags + * parameter can only be '1' (read) or '0' (unread). After a call to + * SetReadFlag(), GetMessageList() should return the message with the + * new 'flags' but should not modify the 'mod' parameter. If you do + * change 'mod', simply setting the message to 'read' on the PDA will trigger + * a full resync of the item from the server + */ + function SetReadFlag($folderid, $id, $flags) + { + return false; + } + + /** + * modify olflags (outlook style) flag of a message + * + * @param $folderid + * @param $id + * @param $flags + * + * + * @DESC The $flags parameter must contains the poommailflag Object + */ + function ChangeMessageFlag($folderid, $id, $flags) + { + return false; + } + + /** + * Return a changes array + * + * if changes occurr default diff engine computes the actual changes + * + * @param string $folderid + * @param string &$syncstate on call old syncstate, on return new syncstate + * @return array|boolean false if $folderid not found, array() if no changes or array(array("type" => "fakeChange")) + */ + function AlterPingChanges($folderid, &$syncstate) + { + $this->backend->splitID($folderid, $type, $owner); + + if ($type != 'infolog') return false; + + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + if (!($infolog_types = $GLOBALS['egw_info']['user']['preferences']['activesync']['infolog-types'])) + { + $infolog_types = 'task'; + } + + $ctag = $this->infolog->getctag(array( + 'filter' => $owner == $GLOBALS['egw_info']['user']['account_id'] ? 'own' : 'user'.$owner, + 'info_type' => explode(',', $infolog_types), + )); + + $changes = array(); // no change + $syncstate_was = $syncstate; + + if ($ctag !== $syncstate) + { + $syncstate = $ctag; + $changes = array(array('type' => 'fakeChange')); + } + //debugLog(__METHOD__."('$folderid','$syncstate_was') syncstate='$syncstate' returning ".array2string($changes)); + return $changes; + } + + /** + * Populates $settings for the preferences + * + * @param array|string $hook_data + * @return array + */ + function egw_settings($hook_data) + { + if (!$hook_data['setup']) translation::add_app('infolog'); + if (!isset($this->infolog)) $this->infolog = new infolog_bo(); + + if (!($types = $this->infolog->enums['type'])) + { + $types = array( + 'task' => 'Tasks', + ); + } + + $settings['infolog-types'] = array( + 'type' => 'multiselect', + 'label' => 'InfoLog types to sync', + 'name' => 'infolog-types', + 'help' => 'Which InfoLog types should be synced with the device, default only tasks.', + 'values' => $types, + 'default' => 'task', + 'xmlrpc' => True, + 'admin' => False, + ); + + return $settings; + } +} diff --git a/mail/inc/class.mail_zpush.inc.php b/mail/inc/class.mail_zpush.inc.php new file mode 100644 index 0000000000..81375824a7 --- /dev/null +++ b/mail/inc/class.mail_zpush.inc.php @@ -0,0 +1,2089 @@ + + * @author Philip Herbert + * @copyright (c) 2014 by Stylite AG + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @version $Id$ + */ + +/** + * mail eSync plugin + * + * Plugin creates a device specific file to map alphanumeric folder names to nummeric id's. + */ +class mail_activesync implements activesync_plugin_write, activesync_plugin_sendmail, activesync_plugin_meeting_response, activesync_plugin_search_mailbox +{ + /** + * var BackendEGW + */ + private $backend; + + /** + * Instance of mail_bo + * + * @var mail_bo + */ + private $mail; + + /** + * Provides the ability to change the line ending + * @var string + */ + public static $LE = "\n"; + + /** + * Integer id of trash folder + * + * @var mixed + */ + private $_wasteID = false; + + /** + * Integer id of sent folder + * + * @var mixed + */ + private $_sentID = false; + + /** + * Integer id of current mail account / connection + * + * @var int + */ + private $account; + + private $folders; + + private $messages; + + static $profileID; + + /** + * Integer waitOnFailureDefault how long (in seconds) to wait on connection failure + * + * @var int + */ + protected $waitOnFailureDefault = 120; + + /** + * Integer waitOnFailureLimit how long (in seconds) to wait on connection failure until a 500 is raised + * + * @var int + */ + protected $waitOnFailureLimit = 7200; + /** + * debugLevel - enables more debug + * + * @var int + */ + private $debugLevel = 0; + + /** + * Constructor + * + * @param BackendEGW $backend + */ + public function __construct(BackendEGW $backend) + { + if ($GLOBALS['egw_setup']) return; + + //$this->debugLevel=2; + $this->backend = $backend; + if (!isset($GLOBALS['egw_info']['user']['preferences']['activesync']['mail-ActiveSyncProfileID'])) + { + if ($this->debugLevel>1) error_log(__METHOD__.__LINE__.' Noprefs set: using 0 as default'); + // globals preferences add appname varname value + $GLOBALS['egw']->preferences->add('activesync','mail-ActiveSyncProfileID',0,'user'); + // save prefs + $GLOBALS['egw']->preferences->save_repository(true); + } + if ($this->debugLevel>1) error_log(__METHOD__.__LINE__.' ActiveProfileID:'.array2string(self::$profileID)); + + if (is_null(self::$profileID)) + { + if ($this->debugLevel>1) error_log(__METHOD__.__LINE__.' self::ProfileID isNUll:'.array2string(self::$profileID)); + self::$profileID =& egw_cache::getSession('mail','activeSyncProfileID'); + if ($this->debugLevel>1) error_log(__METHOD__.__LINE__.' ActiveProfileID (after reading Cache):'.array2string(self::$profileID)); + } + if (isset($GLOBALS['egw_info']['user']['preferences']['activesync']['mail-ActiveSyncProfileID'])) + { + if ($this->debugLevel>1) error_log(__METHOD__.__LINE__.' Pref for ProfileID:'.array2string($GLOBALS['egw_info']['user']['preferences']['activesync']['mail-ActiveSyncProfileID'])); + if ($GLOBALS['egw_info']['user']['preferences']['activesync']['mail-ActiveSyncProfileID'] == 'G') + { + self::$profileID = 'G'; // this should trigger the fetch of the first negative profile (or if no negative profile is available the firstb there is) + } + else + { + self::$profileID = (int)$GLOBALS['egw_info']['user']['preferences']['activesync']['mail-ActiveSyncProfileID']; + } + } + if ($this->debugLevel>1) error_log(__METHOD__.__LINE__.' Profile Selected (after reading Prefs):'.array2string(self::$profileID)); + + // verify we are on an existing profile, if not running in setup (settings can not be static according to interface!) + if (!isset($GLOBALS['egw_setup'])) + { + try { + emailadmin_account::read(self::$profileID); + } + catch(Exception $e) { + unset($e); + self::$profileID = emailadmin_account::get_default_acc_id(); + } + } + if ($this->debugLevel>0) error_log(__METHOD__.'::'.__LINE__.' ProfileSelected:'.self::$profileID); + //$this->debugLevel=0; + } + + /** + * Populates $settings for the preferences + * + * @param array|string $hook_data + * @return array + */ + function egw_settings($hook_data) + { + //error_log(__METHOD__.__LINE__.array2string($hook_data)); + $identities = array(); + if (!isset($hook_data['setup']) && in_array($hook_data['type'], array('user', 'group'))) + { + $identities = iterator_to_array(emailadmin_account::search((int)$hook_data['account_id'])); + } + $identities += array( + 'G' => lang('Primary Profile'), + ); + + $settings['mail-ActiveSyncProfileID'] = array( + 'type' => 'select', + 'label' => 'eMail Account to sync', + 'name' => 'mail-ActiveSyncProfileID', + 'help' => 'eMail Account to sync ', + 'values' => $identities, + 'default'=> 'G', + 'xmlrpc' => True, + 'admin' => False, + ); + $settings['mail-allowSendingInvitations'] = array( + 'type' => 'select', + 'label' => 'allow sending of calendar invitations using this profile?', + 'name' => 'mail-allowSendingInvitations', + 'help' => 'control the sending of calendar invitations while using this profile', + 'values' => array( + 'sendifnocalnotif'=>'only send if there is no notification in calendar', + 'send'=>'yes, always send', + 'nosend'=>'no, do not send', + ), + 'xmlrpc' => True, + 'default' => 'sendifnocalnotif', + 'admin' => False, + ); + return $settings; + } + + /** + * Verify preferences + * + * @param array|string $hook_data + * @return array with error-messages from all plugins + */ + function verify_settings($hook_data) + { + $errors = array(); + + // check if an eSync eMail profile is set (might not be set as default or forced!) + if (isset($hook_data['prefs']['mail-ActiveSyncProfileID']) || $hook_data['type'] == 'user') + { + // eSync and eMail translations are not (yet) loaded + translation::add_app('activesync'); + translation::add_app('mail'); + + // inject preference to verify and call constructor + $GLOBALS['egw_info']['user']['preferences']['activesync']['mail-ActiveSyncProfileID'] = + $hook_data['prefs']['mail-ActiveSyncProfileID']; + $this->__construct($this->backend); + + try { + $this->_connect(0,true); + $this->_disconnect(); + + if (!$this->_wasteID) $errors[] = lang('No valid %1 folder configured!', ''.lang('trash').''); + if (!$this->_sentID) $errors[] = lang('No valid %1 folder configured!', ''.lang('send').''); + } + catch(Exception $e) { + $errors[] = lang('Can not open IMAP connection').': '.$e->getMessage(); + } + if ($errors) + { + $errors[] = ''.lang('eSync will FAIL without a working eMail configuration!').''; + } + } + //error_log(__METHOD__.'('.array2string($hook_data).') returning '.array2string($errors)); + return $errors; + } + + /** + * Open IMAP connection + * + * @param int $account integer id of account to use + * @param boolean $verify_mode mode used for verify_settings; we want the exception but not the header stuff + * @todo support different accounts + */ + private function _connect($account=0, $verify_mode=false) + { + static $waitOnFailure = null; + if (is_null($account)) $account = 0; + if ($this->mail && $this->account != $account) $this->_disconnect(); + + $hereandnow = egw_time::to('now','ts'); + $this->_wasteID = false; + $this->_sentID = false; + + $connectionFailed = false; + + if ($verify_mode==false && (is_null($waitOnFailure)||empty($waitOnFailure[self::$profileID])||empty($waitOnFailure[self::$profileID][$this->backend->_devid]))) + { + $waitOnFailure = egw_cache::getCache(egw_cache::INSTANCE,'email','ActiveSyncWaitOnFailure'.trim($GLOBALS['egw_info']['user']['account_id']), null, array(), 60*60*2); + } + if (isset($waitOnFailure[self::$profileID]) && !empty($waitOnFailure[self::$profileID]) && !empty($waitOnFailure[self::$profileID][$this->backend->_devid]) && isset($waitOnFailure[self::$profileID][$this->backend->_devid]['lastattempt']) && !empty($waitOnFailure[self::$profileID][$this->backend->_devid]['lastattempt']) && isset($waitOnFailure[self::$profileID][$this->backend->_devid]['howlong']) && !empty($waitOnFailure[self::$profileID][$this->backend->_devid]['howlong'])) + { + if ($waitOnFailure[self::$profileID][$this->backend->_devid]['lastattempt']+$waitOnFailure[self::$profileID][$this->backend->_devid]['howlong']<$hereandnow) + { + if ($this->debugLevel>0) error_log(__METHOD__.__LINE__.'# Instance='.$GLOBALS['egw_info']['user']['domain'].', User='.$GLOBALS['egw_info']['user']['account_lid']." Refuse to open connection for Profile:".self::$profileID.' Device '.$this->backend->_devid.' should still wait '.array2string($waitOnFailure[self::$profileID][$this->backend->_devid])); + header("HTTP/1.1 503 Service Unavailable"); + $hL = $waitOnFailure[self::$profileID][$this->backend->_devid]['lastattempt']+$waitOnFailure[self::$profileID][$this->backend->_devid]['howlong']-$hereandnow; + header("Retry-After: ".$hL); + exit; + } + } + if (!$this->mail) + { + $this->account = $account; + // todo: tell mail which account to use + //error_log(__METHOD__.__LINE__.' create object with ProfileID:'.array2string(self::$profileID)); + try + { + $this->mail = mail_bo::getInstance(false,self::$profileID,true,false,true); + if (self::$profileID == 0 && isset($this->mail->icServer->ImapServerId) && !empty($this->mail->icServer->ImapServerId)) self::$profileID = $this->mail->icServer->ImapServerId; + $this->mail->openConnection(self::$profileID,false); + $connectionFailed = false; + } + catch (Exception $e) + { + $connectionFailed = true; + $errorMessage = $e->getMessage(); + } + } + else + { + //error_log(__METHOD__.__LINE__." connect with profileID: ".self::$profileID); + if (self::$profileID == 0 && isset($this->mail->icServer->ImapServerId) && !empty($this->mail->icServer->ImapServerId)) self::$profileID = $this->mail->icServer->ImapServerId; + try + { + $this->mail->openConnection(self::$profileID,false); + $connectionFailed = false; + } + catch (Exception $e) + { + $connectionFailed = true; + $errorMessage = $e->getMessage(); + } + } + if (empty($waitOnFailure[self::$profileID][$this->backend->_devid])) $waitOnFailure[self::$profileID][$this->backend->_devid] = array('howlong'=>$this->waitOnFailureDefault,'lastattempt'=>$hereandnow); + if ($connectionFailed) + { + // in verify_moode, we want the exeption, but not the exit + if ($verify_mode) + { + throw new egw_exception_not_found(__METHOD__.__LINE__."($account) can not open connection on Profile #".self::$profileID."!".$this->mail->getErrorMessage().' for Instance='.$GLOBALS['egw_info']['user']['domain']); + } + else + { + //error_log(__METHOD__.__LINE__."($account) could not open connection!".$errorMessage); + //error_log(date('Y-m-d H:i:s').' '.__METHOD__.__LINE__."($account) can not open connection!".$this->mail->getErrorMessage()."\n",3,'/var/lib/egroupware/esync-imap.log'); + //error_log('# Instance='.$GLOBALS['egw_info']['user']['domain'].', User='.$GLOBALS['egw_info']['user']['account_lid'].', URL='. + // ($_SERVER['HTTPS']?'https://':'http://').$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']."\n\n",3,'/var/lib/egroupware/esync-imap.log'); + if ($waitOnFailure[self::$profileID][$this->backend->_devid]['howlong'] > $this->waitOnFailureLimit ) + { + $waitOnFailure[self::$profileID][$this->backend->_devid] = array('howlong'=>$this->waitOnFailureDefault,'lastattempt'=>$hereandnow); + egw_cache::setCache(egw_cache::INSTANCE,'email','ActiveSyncWaitOnFailure'.trim($GLOBALS['egw_info']['user']['account_id']),$waitOnFailure,$expiration=60*60*2); + header("HTTP/1.1 500 Internal Server Error"); + throw new egw_exception_not_found(__METHOD__.__LINE__."($account) can not open connection on Profile #".self::$profileID."!".$errorMessage.' for Instance='.$GLOBALS['egw_info']['user']['domain'].', User='.$GLOBALS['egw_info']['user']['account_lid'].', Device:'.$this->backend->_devid); + } + else + { + //error_log(__METHOD__.__LINE__.'# Instance='.$GLOBALS['egw_info']['user']['domain'].', User='.$GLOBALS['egw_info']['user']['account_lid']." Can not open connection for Profile:".self::$profileID.' Device:'.$this->backend->_devid.' should wait '.array2string($waitOnFailure[self::$profileID][$this->backend->_devid])); + $waitaslongasthis = $waitOnFailure[self::$profileID][$this->backend->_devid]['howlong']; + $waitOnFailure[self::$profileID][$this->backend->_devid] = array('howlong'=>(empty($waitOnFailure[self::$profileID][$this->backend->_devid]['howlong'])?$this->waitOnFailureDefault:$waitOnFailure[self::$profileID][$this->backend->_devid]['howlong']) * 2,'lastattempt'=>$hereandnow); + egw_cache::setCache(egw_cache::INSTANCE,'email','ActiveSyncWaitOnFailure'.trim($GLOBALS['egw_info']['user']['account_id']),$waitOnFailure,$expiration=60*60*2); + header("HTTP/1.1 503 Service Unavailable"); + header("Retry-After: ".$waitaslongasthis); + $ethrown = new egw_exception_not_found(__METHOD__.__LINE__."($account) can not open connection on Profile #".self::$profileID."!".$errorMessage.' for Instance='.$GLOBALS['egw_info']['user']['domain'].', User='.$GLOBALS['egw_info']['user']['account_lid'].', Device:'.$this->backend->_devid." Should wait for:".$waitaslongasthis.'(s)'.' WaitInfoStored2Cache:'.array2string($waitOnFailure)); + _egw_log_exception($ethrown); + exit; + } + } + //die('Mail not or mis-configured!'); + } + else + { + if (!empty($waitOnFailure[self::$profileID][$this->backend->_devid])) + { + $waitOnFailure[self::$profileID][$this->backend->_devid] = array(); + egw_cache::setCache(egw_cache::INSTANCE,'email','ActiveSyncWaitOnFailure'.trim($GLOBALS['egw_info']['user']['account_id']),$waitOnFailure,$expiration=60*60*2); + } + } + $this->_wasteID = $this->mail->getTrashFolder(false); + //error_log(__METHOD__.__LINE__.' TrashFolder:'.$this->_wasteID); + $this->_sentID = $this->mail->getSentFolder(false); + $this->mail->getOutboxFolder(true); + //error_log(__METHOD__.__LINE__.' SentFolder:'.$this->_sentID); + //error_log(__METHOD__.__LINE__.' Connection Status for ProfileID:'.self::$profileID.'->'.$this->mail->icServer->_connected); + } + + /** + * Close IMAP connection + */ + private function _disconnect() + { + debugLog(__METHOD__); + if ($this->mail) $this->mail->closeConnection(); + + unset($this->mail); + unset($this->account); + unset($this->folders); + } + + /** + * GetFolderList + * + * @ToDo loop over available email accounts + */ + public function GetFolderList() + { + $folderlist = array(); + debugLog(__METHOD__.__LINE__); + /*foreach($available_accounts as $account)*/ $account = 0; + { + $this->_connect($account); + if (!isset($this->folders)) $this->folders = $this->mail->getFolderObjects(true,false,$_alwaysGetDefaultFolders=true); + debugLog(__METHOD__.__LINE__.array2string($this->folders)); + + foreach ($this->folders as $folder => $folderObj) { + debugLog(__METHOD__.__LINE__.' folder='.$folder); + $folderlist[] = $f = array( + 'id' => $this->createID($account,$folder), + 'mod' => $folderObj->shortDisplayName, + 'parent' => $this->getParentID($account,$folder), + ); + if ($this->debugLevel>0) debugLog(__METHOD__."() returning ".array2string($f)); + } + } + debugLog(__METHOD__."() returning ".array2string($folderlist)); + + return $folderlist; + } + + /** + * Sends a message which is passed as rfc822. You basically can do two things + * 1) Send the message to an SMTP server as-is + * 2) Parse the message yourself, and send it some other way + * It is up to you whether you want to put the message in the sent items folder. If you + * want it in 'sent items', then the next sync on the 'sent items' folder should return + * the new message as any other new message in a folder. + * + * @param string $rfc822 mail + * @param array $smartdata =array() values for keys: + * 'task': 'forward', 'new', 'reply' + * 'itemid': id of message if it's an reply or forward + * 'folderid': folder + * 'replacemime': false = send as is, false = decode and recode for whatever reason ??? + * 'saveinsentitems': 1 or absent? + * @param boolean|double $protocolversion =false + * @return boolean true on success, false on error + * + * @see eg. BackendIMAP::SendMail() + * @todo implement either here or in mail backend + * (maybe sending here and storing to sent folder in plugin, as sending is supposed to always work in EGroupware) + */ + public function SendMail($rfc822, $smartdata=array(), $protocolversion = false) + { + //$this->debugLevel=3; + $ClientSideMeetingRequest = false; + $allowSendingInvitations = 'sendifnocalnotif'; + if (isset($GLOBALS['egw_info']['user']['preferences']['activesync']['mail-allowSendingInvitations']) && + $GLOBALS['egw_info']['user']['preferences']['activesync']['mail-allowSendingInvitations']=='nosend') + { + $allowSendingInvitations = false; + } + elseif (isset($GLOBALS['egw_info']['user']['preferences']['activesync']['mail-allowSendingInvitations']) && + $GLOBALS['egw_info']['user']['preferences']['activesync']['mail-allowSendingInvitations']!='nosend') + { + $allowSendingInvitations = $GLOBALS['egw_info']['user']['preferences']['activesync']['mail-allowSendingInvitations']; + } + + if ($protocolversion < 14.0) + debugLog("IMAP-SendMail: " . (isset($rfc822) ? $rfc822 : ""). "task: ".(isset($smartdata['task']) ? $smartdata['task'] : "")." itemid: ".(isset($smartdata['itemid']) ? $smartdata['itemid'] : "")." folder: ".(isset($smartdata['folderid']) ? $smartdata['folderid'] : "")); + if ($this->debugLevel>0) debugLog("IMAP-Sendmail: Smartdata = ".array2string($smartdata)); + //error_log("IMAP-Sendmail: Smartdata = ".array2string($smartdata)); + + // initialize our mail_bo + if (!isset($this->mail)) $this->mail = mail_bo::getInstance(false,self::$profileID,true,false,true); + $activeMailProfiles = $this->mail->getAccountIdentities(self::$profileID); + // use the standardIdentity + $activeMailProfile = mail_bo::getStandardIdentityForProfile($activeMailProfiles,self::$profileID); + + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__.' ProfileID:'.self::$profileID.' ActiveMailProfile:'.array2string($activeMailProfile)); + + // initialize the new egw_mailer object for sending + $mailObject = new egw_mailer(); + $this->mail->parseRawMessageIntoMailObject($mailObject,$rfc822); + // Horde SMTP Class uses utf-8 by default. as we set charset always to utf-8 + $mailObject->Sender = $activeMailProfile['ident_email']; + $mailObject->From = $activeMailProfile['ident_email']; + $mailObject->FromName = $mailObject->EncodeHeader(mail_bo::generateIdentityString($activeMailProfile,false)); + $mailObject->AddCustomHeader('X-Mailer: mail-Activesync'); + + + // prepare addressee list; moved the adding of addresses to the mailobject down + // to + + foreach(emailadmin_imapbase::parseAddressList($mailObject->getHeader("To")) as $addressObject) { + if (!$addressObject->valid) continue; + if ($this->debugLevel>0) debugLog("Header Sentmail To: ".array2string($addressObject) ); + //$mailObject->AddAddress($addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''),$addressObject->personal); + $toMailAddr[] = imap_rfc822_write_address($addressObject->mailbox, $addressObject->host, $addressObject->personal); + } + // CC + foreach(emailadmin_imapbase::parseAddressList($mailObject->getHeader("Cc")) as $addressObject) { + if (!$addressObject->valid) continue; + if ($this->debugLevel>0) debugLog("Header Sentmail CC: ".array2string($addressObject) ); + //$mailObject->AddCC($addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''),$addressObject->personal); + $ccMailAddr[] = imap_rfc822_write_address($addressObject->mailbox, $addressObject->host, $addressObject->personal); + } + // BCC + foreach(emailadmin_imapbase::parseAddressList($mailObject->getHeader("Bcc")) as $addressObject) { + if (!$addressObject->valid) continue; + if ($this->debugLevel>0) debugLog("Header Sentmail BCC: ".array2string($addressObject) ); + //$mailObject->AddBCC($addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''),$addressObject->personal); + $bccMailAddr[] = imap_rfc822_write_address($addressObject->mailbox, $addressObject->host, $addressObject->personal); + } + $mailObject->clearAllRecipients(); + + $use_orgbody = false; + + $k = 'Content-Type'; + $ContentType =$mailObject->getHeader('Content-Type'); + //error_log(__METHOD__.__LINE__." Header Sentmail original Header (filtered): " . $k. " = ".trim($ContentType)); + // if the message is a multipart message, then we should use the sent body + if (preg_match("/multipart/i", $ContentType)) { + $use_orgbody = true; + } + + // save the original content-type header for the body part when forwarding + if ($smartdata['task'] == 'forward' && $smartdata['itemid'] && !$use_orgbody) { + //continue; // ignore + } + // horde/egw_ mailer does everything as utf-8, the following should not be needed + //$org_charset = $ContentType; + //$ContentType = preg_replace("/charset=([A-Za-z0-9-\"']+)/", "charset=\"utf-8\"", $ContentType); + // if the message is a multipart message, then we should use the sent body + if (($smartdata['task'] == 'new' || $smartdata['task'] == 'reply' || $smartdata['task'] == 'forward') && + ((isset($smartdata['replacemime']) && $smartdata['replacemime'] == true) || + $k == "Content-Type" && preg_match("/multipart/i", $ContentType))) { + $use_orgbody = true; + } + $Body = $AltBody = ""; + // get body of the transmitted message + // if this is a simple message, no structure at all + if (preg_match("/text/i", $ContentType)) + { + $simpleBodyType = (preg_match("/html/i", $ContentType)?'text/html':'text/plain'); + $bodyObj = $mailObject->findBody(preg_match("/html/i", $ContentType) ? 'html' : 'plain'); + $body = preg_replace("/(<|<)*(([\w\.,-.,_.,0-9.]+)@([\w\.,-.,_.,0-9.]+))(>|>)*/i","[$2]", $bodyObj ?$bodyObj->getContents() : null); + if ($simpleBodyType == "text/plain") + { + $Body = $body; + $AltBody = "
".nl2br($body)."
"; + if ($this->debugLevel>1) debugLog("IMAP-Sendmail: fetched Body as :". $simpleBodyType.'=> Created AltBody'); + } + else + { + $AltBody = $body; + $Body = trim(translation::convertHTMLToText($body)); + if ($this->debugLevel>1) debugLog("IMAP-Sendmail: fetched Body as :". $simpleBodyType.'=> Created Body'); + } + } + else + { + // if this is a structured message + // prefer plain over html + $Body = preg_replace("/(<|<)*(([\w\.,-.,_.,0-9.]+)@([\w\.,-.,_.,0-9.]+))(>|>)*/i","[$2]", + ($text_body = $mailObject->findBody('plain')) ? $text_body->getContents() : null); + $AltBody = preg_replace("/(<|<)*(([\w\.,-.,_.,0-9.]+)@([\w\.,-.,_.,0-9.]+))(>|>)*/i","[$2]", + ($html_body = $mailObject->findBody('html')) ? $html_body->getContents() : null); + } + if ($this->debugLevel>1 && $Body) debugLog("IMAP-Sendmail: fetched Body as with MessageContentType:". $ContentType.'=>'.$Body); + if ($this->debugLevel>1 && $AltBody) debugLog("IMAP-Sendmail: fetched AltBody as with MessageContentType:". $ContentType.'=>'.$AltBody); + //error_log(__METHOD__.__LINE__.array2string($mailObject)); + // if this is a multipart message with a boundary, we must use the original body + //if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__.' mailObject after Inital Parse:'.array2string($mailObject)); + if ($use_orgbody) { + if ($this->debugLevel>0) debugLog("IMAP-Sendmail: use_orgbody = true ContentType:".$ContentType); + // if it is a ClientSideMeetingRequest, we report it as send at all times + if (stripos($ContentType,'text/calendar') !== false ) + { + $body = ($text_body = $mailObject->findBody('calendar')) ? $text_body->getContents() : null; + $Body = $body; + $AltBody = "
".nl2br($body)."
"; + if ($this->debugLevel>0) debugLog("IMAP-Sendmail: we have a Client Side Meeting Request"); + // try figuring out the METHOD -> [ContentType] => text/calendar; name=meeting.ics; method=REQUEST + $tA = explode(' ',$ContentType); + foreach ((array)$tA as $k => $p) + { + if (stripos($p,"method=")!==false) $cSMRMethod= trim(str_replace('METHOD=','',strtoupper($p))); + } + $ClientSideMeetingRequest = true; + } + } + // now handle the addressee list + $toCount = 0; + //error_log(__METHOD__.__LINE__.array2string($toMailAddr)); + foreach((array)$toMailAddr as $address) { + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($address):$address)) as $addressObject) { + $emailAddress = $addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''); + if ($ClientSideMeetingRequest === true && $allowSendingInvitations == 'sendifnocalnotif' && calendar_boupdate::email_update_requested($emailAddress,(isset($cSMRMethod)?$cSMRMethod:'REQUEST'))) continue; + $mailObject->AddAddress($emailAddress, $addressObject->personal); + $toCount++; + } + } + $ccCount = 0; + foreach((array)$ccMailAddr as $address) { + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($address):$address)) as $addressObject) { + $emailAddress = $addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''); + if ($ClientSideMeetingRequest === true && $allowSendingInvitations == 'sendifnocalnotif' && calendar_boupdate::email_update_requested($emailAddress)) continue; + $mailObject->AddCC($emailAddress, $addressObject->personal); + $ccCount++; + } + } + $bccCount = 0; + foreach((array)$bccMailAddr as $address) { + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($address):$address)) as $addressObject) { + $emailAddress = $addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''); + if ($ClientSideMeetingRequest === true && $allowSendingInvitations == 'sendifnocalnotif' && calendar_boupdate::email_update_requested($emailAddress)) continue; + $mailObject->AddBCC($emailAddress, $addressObject->personal); + $bccCount++; + } + } + if ($toCount+$ccCount+$bccCount == 0) return 0; // noone to send mail to + if ($ClientSideMeetingRequest === true && $allowSendingInvitations===false) return true; + // as we use our mailer (horde mailer) it is detecting / setting the mimetype by itself while creating the mail +/* + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__.' retrieved Body:'.$body); + $body = str_replace("\r",((preg_match("^text/html^i", $ContentType))?'
':""),$body); // what is this for? + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__.' retrieved Body (modified):'.$body); +*/ + // add signature!! ----------------------------------------------------------------- + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__.' ActiveMailProfile:'.array2string($activeMailProfile)); + try + { + $acc = emailadmin_account::read($this->mail->icServer->ImapServerId); + //error_log(__METHOD__.__LINE__.array2string($acc)); + $_signature = emailadmin_account::read_identity($acc['ident_id'],true); + } + catch (Exception $e) + { + $_signature=array(); + } + $signature = $_signature['ident_signature']; + if ((isset($preferencesArray['disableRulerForSignatureSeparation']) && + $preferencesArray['disableRulerForSignatureSeparation']) || + empty($signature) || trim(translation::convertHTMLToText($signature)) =='') + { + $disableRuler = true; + } + $beforePlain = $beforeHtml = ""; + $beforeHtml = ($disableRuler ?' 
':' 

'); + $beforePlain = ($disableRuler ?"\r\n\r\n":"\r\n\r\n-- \r\n"); + $sigText = emailadmin_imapbase::merge($signature,array($GLOBALS['egw']->accounts->id2name($GLOBALS['egw_info']['user']['account_id'],'person_id'))); + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' Signature to use:'.$sigText); + $sigTextHtml = $beforeHtml.$sigText; + $sigTextPlain = $beforePlain.translation::convertHTMLToText($sigText); + $isreply = $isforward = false; + // reply --------------------------------------------------------------------------- + if ($smartdata['task'] == 'reply' && isset($smartdata['itemid']) && + isset($smartdata['folderid']) && $smartdata['itemid'] && $smartdata['folderid'] && + (!isset($smartdata['replacemime']) || + (isset($smartdata['replacemime']) && $smartdata['replacemime'] == false))) + { + // now get on, and fetch the original mail + $uid = $smartdata['itemid']; + if ($this->debugLevel>0) debugLog("IMAP Smartreply is called with FolderID:".$smartdata['folderid'].' and ItemID:'.$smartdata['itemid']); + $this->splitID($smartdata['folderid'], $account, $folder); + + $this->mail->reopen($folder); + $bodyStruct = $this->mail->getMessageBody($uid, 'html_only'); + $bodyBUFFHtml = $this->mail->getdisplayableBody($this->mail,$bodyStruct,true); + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' html_only:'.$bodyBUFFHtml); + if ($bodyBUFFHtml != "" && (is_array($bodyStruct) && $bodyStruct[0]['mimeType']=='text/html')) { + // may be html + if ($this->debugLevel>0) debugLog("MIME Body".' Type:html (fetched with html_only):'.$bodyBUFFHtml); + $AltBody = $AltBody."
".$bodyBUFFHtml.$sigTextHtml; + $isreply = true; + } + // plain text Message part + if ($this->debugLevel>0) debugLog("MIME Body".' Type:plain, fetch text:'); + // if the new part of the message is html, we must preserve it, and handle that the original mail is text/plain + $bodyStruct = $this->mail->getMessageBody($uid,'never_display');//'never_display'); + $bodyBUFF = $this->mail->getdisplayableBody($this->mail,$bodyStruct);//$this->ui->getdisplayableBody($bodyStruct,false); + if ($bodyBUFF != "" && (is_array($bodyStruct) && $bodyStruct[0]['mimeType']=='text/plain')) { + if ($this->debugLevel>0) debugLog("MIME Body".' Type:plain (fetched with never_display):'.$bodyBUFF); + $Body = $Body."\r\n".$bodyBUFF.$sigTextPlain; + $isreply = true; + } + if (!empty($bodyBUFF) && empty($bodyBUFFHtml) && !empty($AltBody)) + { + $isreply = true; + $AltBody = $AltBody."
".nl2br($bodyBUFF).'
'.$sigTextHtml; + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__." no html Body found use modified plaintext body for txt/html: ".$AltBody); + } + } + + // how to forward and other prefs + $preferencesArray =& $GLOBALS['egw_info']['user']['preferences']['mail']; + + // forward ------------------------------------------------------------------------- + if ($smartdata['task'] == 'forward' && isset($smartdata['itemid']) && + isset($smartdata['folderid']) && $smartdata['itemid'] && $smartdata['folderid'] && + (!isset($smartdata['replacemime']) || + (isset($smartdata['replacemime']) && $smartdata['replacemime'] == false))) + { + //force the default for the forwarding -> asmail + if (is_array($preferencesArray)) { + if (!array_key_exists('message_forwarding',$preferencesArray) + || !isset($preferencesArray['message_forwarding']) + || empty($preferencesArray['message_forwarding'])) $preferencesArray['message_forwarding'] = 'asmail'; + } else { + $preferencesArray['message_forwarding'] = 'asmail'; + } + // construct the uid of the message out of the itemid - seems to be the uid, no construction needed + $uid = $smartdata['itemid']; + if ($this->debugLevel>0) debugLog("IMAP Smartfordward is called with FolderID:".$smartdata['folderid'].' and ItemID:'.$smartdata['itemid']); + $this->splitID($smartdata['folderid'], $account, $folder); + + $this->mail->reopen($folder); + // receive entire mail (header + body) + // get message headers for specified message + $headers = $this->mail->getMessageEnvelope($uid, $_partID, true, $folder); + // build a new mime message, forward entire old mail as file + if ($preferencesArray['message_forwarding'] == 'asmail') + { + $rawHeader=''; + $rawHeader = $this->mail->getMessageRawHeader($smartdata['itemid'], $_partID,$folder); + $rawBody = $this->mail->getMessageRawBody($smartdata['itemid'], $_partID,$folder); + $mailObject->AddStringAttachment($rawHeader.$rawBody, $headers['SUBJECT'].'.eml', 'message/rfc822'); + $AltBody = $AltBody."
".lang("See Attachments for Content of the Orignial Mail").$sigTextHtml; + $Body = $Body."\r\n".lang("See Attachments for Content of the Orignial Mail").$sigTextPlain; + $isforward = true; + } + else + { + // now get on, and fetch the original mail + $uid = $smartdata['itemid']; + if ($this->debugLevel>0) debugLog("IMAP Smartreply is called with FolderID:".$smartdata['folderid'].' and ItemID:'.$smartdata['itemid']); + $this->splitID($smartdata['folderid'], $account, $folder); + + $this->mail->reopen($folder); + $bodyStruct = $this->mail->getMessageBody($uid, 'html_only'); + $bodyBUFFHtml = $this->mail->getdisplayableBody($this->mail,$bodyStruct,true); + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' html_only:'.$bodyBUFFHtml); + if ($bodyBUFFHtml != "" && (is_array($bodyStruct) && $bodyStruct[0]['mimeType']=='text/html')) { + // may be html + if ($this->debugLevel>0) debugLog("MIME Body".' Type:html (fetched with html_only):'.$bodyBUFFHtml); + $AltBody = $AltBody."
".$bodyBUFFHtml.$sigTextHtml; + $isforward = true; + } + // plain text Message part + if ($this->debugLevel>0) debugLog("MIME Body".' Type:plain, fetch text:'); + // if the new part of the message is html, we must preserve it, and handle that the original mail is text/plain + $bodyStruct = $this->mail->getMessageBody($uid,'never_display');//'never_display'); + $bodyBUFF = $this->mail->getdisplayableBody($this->mail,$bodyStruct);//$this->ui->getdisplayableBody($bodyStruct,false); + if ($bodyBUFF != "" && (is_array($bodyStruct) && $bodyStruct[0]['mimeType']=='text/plain')) { + if ($this->debugLevel>0) debugLog("MIME Body".' Type:plain (fetched with never_display):'.$bodyBUFF); + $Body = $Body."\r\n".$bodyBUFF.$sigTextPlain; + $isforward = true; + } + if (!empty($bodyBUFF) && empty($bodyBUFFHtml) && !empty($AltBody)) + { + $AltBody = $AltBody."
".nl2br($bodyBUFF).'
'.$sigTextHtml; + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__." no html Body found use modified plaintext body for txt/html: ".$AltBody); + $isforward = true; + } + // get all the attachments and add them too. + // start handle Attachments + // $_uid, $_partID=null, Horde_Mime_Part $_structure=null, $fetchEmbeddedImages=true, $fetchTextCalendar=false, $resolveTNEF=true, $_folderName='' + $attachments = $this->mail->getMessageAttachments($uid, null, null, true, false, true , $folder); + $attachmentNames = false; + if (is_array($attachments) && count($attachments)>0) + { + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' gather Attachments for BodyCreation of/for MessageID:'.$uid.' found:'.count($attachments)); + foreach((array)$attachments as $key => $attachment) + { + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' Key:'.$key.'->'.array2string($attachment)); + $attachmentNames .= $attachment['name']."\n"; + $attachmentData = ''; + $attachmentData = $this->mail->getAttachment($uid, $attachment['partID'],0,false,false,$folder); + /*$x =*/ $mailObject->AddStringAttachment($attachmentData['attachment'], $mailObject->EncodeHeader($attachment['name']), $attachment['mimeType']); + //debugLog(__METHOD__.__LINE__.' added part with number:'.$x); + } + } + } + } // end forward + // add signature, in case its not already added in forward or reply + if (!$isreply && !$isforward) + { + $Body = $Body.$sigTextPlain; + $AltBody = $AltBody.$sigTextHtml; + } + // now set the body + if ($AltBody && ($html_body = $mailObject->findBody('html'))) + { + if ($this->debugLevel>1) debugLog(__METHOD__.__LINE__.' -> '.$AltBody); + $html_body->setContents($AltBody,array('encoding'=>Horde_Mime_Part::DEFAULT_ENCODING)); + } + if ($Body && ($text_body = $mailObject->findBody('plain'))) + { + if ($this->debugLevel>1) debugLog(__METHOD__.__LINE__.' -> '.$Body); + $text_body->setContents($Body,array('encoding'=>Horde_Mime_Part::DEFAULT_ENCODING)); + } + //advanced debugging + // Horde SMTP Class uses utf-8 by default. + //debugLog("IMAP-SendMail: parsed message: ". print_r($message,1)); + if ($this->debugLevel>2) debugLog("IMAP-SendMail: MailObject:".array2string($mailObject)); + + // set a higher timeout for big messages + @set_time_limit(120); + + // send + $send = true; + try { + $mailObject->Send(); + } + catch(phpmailerException $e) { + debugLog("The email could not be sent. Last-SMTP-error: ". $e->getMessage()); + $send = false; + } + + if (( $smartdata['task'] == 'reply' || $smartdata['task'] == 'forward') && $send == true) + { + $uid = $smartdata['itemid']; + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' tASK:'.$smartdata['task']." FolderID:".$smartdata['folderid'].' and ItemID:'.$smartdata['itemid']); + $this->splitID($smartdata['folderid'], $account, $folder); + //error_log(__METHOD__.__LINE__.' Folder:'.$folder.' Uid:'.$uid); + $this->mail->reopen($folder); + // if the draft folder is a starting part of the messages folder, the draft message will be deleted after the send + // unless your templatefolder is a subfolder of your draftfolder, and the message is in there + if ($this->mail->isDraftFolder($folder) && !$this->mail->isTemplateFolder($folder)) + { + $this->mail->deleteMessages(array($uid),$folder); + } else { + $this->mail->flagMessages("answered", array($uid),$folder); + if ($smartdata['task']== "forward") + { + $this->mail->flagMessages("forwarded", array($uid),$folder); + } + } + } + + $asf = ($send ? true:false); // initalize accordingly + if (($smartdata['saveinsentitems']==1 || !isset($smartdata['saveinsentitems'])) && $send==true && $this->mail->mailPreferences['sendOptions'] != 'send_only') + { + $asf = false; + $sentFolder = $this->mail->getSentFolder(); + if ($this->_sentID) { + $folderArray[] = $this->_sentID; + } + else if(isset($sentFolder) && $sentFolder != 'none') + { + $folderArray[] = $sentFolder; + } + // No Sent folder set, try defaults + else + { + debugLog("IMAP-SendMail: No Sent mailbox set"); + // we dont try guessing + $asf = true; + } + if (count($folderArray) > 0) { + foreach((array)$bccMailAddr as $address) { + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($address):$address)) as $addressObject) { + $emailAddress = $addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''); + $mailAddr[] = array($emailAddress, $addressObject->personal); + } + } + $BCCmail=''; + if (count($mailAddr)>0) $BCCmail = $mailObject->AddrAppend("Bcc",$mailAddr); + foreach($folderArray as $folderName) { + if($this->mail->isSentFolder($folderName)) { + $flags = '\\Seen'; + } elseif($this->mail->isDraftFolder($folderName)) { + $flags = '\\Draft'; + } else { + $flags = ''; + } + $asf = true; + //debugLog(__METHOD__.__LINE__.'->'.array2string($this->mail->icServer)); + $this->mail->openConnection(self::$profileID,false); + if ($this->mail->folderExists($folderName)) { + try + { + $this->mail->appendMessage($folderName,$mailObject->getRaw(), null, + $flags); + } + catch (egw_exception_wrong_userinput $e) + { + //$asf = false; + debugLog(__METHOD__.__LINE__.'->'.lang("Import of message %1 failed. Could not save message to folder %2 due to: %3",$mailObject->getHeader('Subject'),$folderName,$e->getMessage())); + } + } + else + { + //$asf = false; + debugLog(__METHOD__.__LINE__.'->'.lang("Import of message %1 failed. Destination Folder %2 does not exist.",$mailObject->getHeader('Subject'),$folderName)); + } + debugLog("IMAP-SendMail: Outgoing mail saved in configured 'Sent' folder '".$folderName."': ". (($asf)?"success":"failed")); + } + //$this->mail->closeConnection(); + } + } + + //$this->debugLevel=0; + + if ($send && $asf) + { + return true; + } + else + { + debugLog(__METHOD__." returning ".($ClientSideMeetingRequest ? true : 120)." (MailSubmissionFailed)".($ClientSideMeetingRequest ?" is ClientSideMeetingRequest (we ignore the failure)":"")); + return ($ClientSideMeetingRequest ? true : 120); //MAIL Submission failed, see MS-ASCMD + } + + } + + /** + * For meeting requests (iCal attachments with method='request') we call calendar plugin with iCal to get SyncMeetingRequest object, + * and do NOT return the attachment itself! + * + * @param string $folderid + * @param string $id + * @param ContentParameters $contentparameters parameters of the requested message (truncation, mimesupport etc) + * object with attributes foldertype, truncation, rtftruncation, conflict, filtertype, bodypref, deletesasmoves, filtertype, contentclass, mimesupport, conversationmode + * bodypref object with attributes: ]truncationsize, allornone, preview + * @return $messageobject|boolean false on error + */ + public function GetMessage($folderid, $id, $contentparameters) + { + debugLog(__METHOD__.__LINE__.' FolderID:'.$folderid.' ID:'.$id); + $truncsize = Utils::GetTruncSize($contentparameters->GetTruncation()); + $mimesupport = $contentparameters->GetMimeSupport(); + $bodypreference = $contentparameters->GetBodyPreference(); /* fmbiete's contribution r1528, ZP-320 */ + + //$this->debugLevel=4; + if (!isset($this->mail)) $this->mail = mail_bo::getInstance(false,self::$profileID,true,false,true); + debugLog(__METHOD__.__LINE__.' FolderID:'.$folderid.' ID:'.$id.' TruncSize:'.$truncsize.' Bodypreference: '.array2string($bodypreference)); + $account = $_folderName = $xid = null; + $this->splitID($folderid,$account,$_folderName,$xid); + $this->mail->reopen($_folderName); + $stat = $this->StatMessage($folderid, $id); + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.array2string($stat)); + // StatMessage should reopen the folder in question, so we dont need folderids in the following statements. + if ($stat) + { + debugLog(__METHOD__.__LINE__." Message $id with stat ".array2string($stat)); + // initialize the object + $output = new SyncMail(); + $headers = $this->mail->getMessageHeader($id,'',true,true,$_folderName); + if (empty($headers)) + { + error_log(__METHOD__.__LINE__.' Retrieval of Headers Failed! for .'.$this->account.'/'.$GLOBALS['egw_info']['user']['account_lid'].' ServerID:'.self::$profileID.'FolderID:'.$folderid.'/'.$_folderName.' ID:'.$id.' TruncSize:'.$truncsize.' Bodypreference: '.array2string($bodypreference).' Stat was:'.array2string($stat)); + return $output;//empty object + } + //$rawHeaders = $this->mail->getMessageRawHeader($id); + // simple style + // start AS12 Stuff (bodypreference === false) case = old behaviour + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__. ' for message with ID:'.$id.' with headers:'.array2string($headers)); + if ($bodypreference === false) { + $bodyStruct = $this->mail->getMessageBody($id, 'only_if_no_text', '', null, true,$_folderName); + $raw_body = $this->mail->getdisplayableBody($this->mail,$bodyStruct); + //$body = html_entity_decode($body,ENT_QUOTES,$this->mail->detect_encoding($body)); + if (stripos($raw_body,'/is", "", $raw_body); // in case there is only a html part + // remove all other html + $body = strip_tags($raw_body); + if(strlen($body) > $truncsize) { + $body = utf8_truncate($body, $truncsize); + $output->bodytruncated = 1; + } + else + { + $output->bodytruncated = 0; + } + $output->bodysize = strlen($body); + $output->body = $body; + } + else // style with bodypreferences + { + if (isset($bodypreference[1]) && !isset($bodypreference[1]["TruncationSize"])) + $bodypreference[1]["TruncationSize"] = 1024*1024; + if (isset($bodypreference[2]) && !isset($bodypreference[2]["TruncationSize"])) + $bodypreference[2]["TruncationSize"] = 1024*1024; + if (isset($bodypreference[3]) && !isset($bodypreference[3]["TruncationSize"])) + $bodypreference[3]["TruncationSize"] = 1024*1024; + if (isset($bodypreference[4]) && !isset($bodypreference[4]["TruncationSize"])) + $bodypreference[4]["TruncationSize"] = 1024*1024; + // set the protocoll class + $output->asbody = new SyncBaseBody(); + // fetch the body (try to gather data only once) + $css =''; + $bodyStruct = $this->mail->getMessageBody($id, 'html_only', '', null, true,$_folderName); + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__.' html_only Struct:'.array2string($bodyStruct)); + $body = $this->mail->getdisplayableBody($this->mail,$bodyStruct,true);//$this->ui->getdisplayableBody($bodyStruct,false); + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' html_only:'.$body); + if ($body != "" && (is_array($bodyStruct) && $bodyStruct[0]['mimeType']=='text/html')) { + // may be html + if ($this->debugLevel>0) debugLog("MIME Body".' Type:html (fetched with html_only)'); + $css = $this->mail->getStyles($bodyStruct); + $output->nativebodytype=2; + } else { + // plain text Message + if ($this->debugLevel>0) debugLog("MIME Body".' Type:plain, fetch text (HTML, if no text available)'); + $output->nativebodytype=1; + $bodyStruct = $this->mail->getMessageBody($id,'never_display', '', null, true,$_folderName); //'only_if_no_text'); + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' plain text Struct:'.array2string($bodyStruct)); + $body = $this->mail->getdisplayableBody($this->mail,$bodyStruct);//$this->ui->getdisplayableBody($bodyStruct,false); + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' never display html(plain text only):'.$body); + } + // whatever format decode (using the correct encoding) + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__."MIME Body".' Type:'.($output->nativebodytype==2?' html ':' plain ').$body); + //$body = html_entity_decode($body,ENT_QUOTES,$this->mail->detect_encoding($body)); + // prepare plaintextbody + if ($output->nativebodytype == 2) + { + $bodyStructplain = $this->mail->getMessageBody($id,'never_display', '', null, true,$_folderName); //'only_if_no_text'); + if($bodyStructplain[0]['error']==1) + { + $plainBody = translation::convertHTMLToText($body); // always display with preserved HTML + } + else + { + $plainBody = $this->mail->getdisplayableBody($this->mail,$bodyStructplain);//$this->ui->getdisplayableBody($bodyStruct,false); + } + } + //if ($this->debugLevel>0) debugLog("MIME Body".$body); + $plainBody = preg_replace("//is", "", (strlen($plainBody)?$plainBody:$body)); + // remove all other html + $plainBody = preg_replace("//is","\r\n",$plainBody); + $plainBody = strip_tags($plainBody); + if ($this->debugLevel>3 && $output->nativebodytype==1) debugLog(__METHOD__.__LINE__.' Plain Text:'.$plainBody); + //$body = str_replace("\n","\r\n", str_replace("\r","",$body)); // do we need that? + if (isset($bodypreference[4]) && ($mimesupport==2 || ($mimesupport ==1 && stristr($headers['CONTENT-TYPE'],'signed') !== false))) + { + debugLog(__METHOD__.__LINE__." bodypreference 4 requested"); + $output->asbody->type = 4; + //$rawBody = $this->mail->getMessageRawBody($id); + $mailObject = new egw_mailer(); + // this try catch block is probably of no use anymore, as it was intended to catch exceptions thrown by parseRawMessageIntoMailObject + try + { + // we create a complete new rfc822 message here to pass a clean one to the client. + // this strips a lot of information, but ... + $Header = $Body = ''; + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__." Creation of Mailobject."); + //if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__." Using data from ".$rawHeaders.$rawBody); + //$this->mail->parseRawMessageIntoMailObject($mailObject,$rawHeaders.$rawBody,$Header,$Body); + //debugLog(__METHOD__.__LINE__.array2string($headers)); + // now force UTF-8, Horde SMTP Class uses utf-8 by default. + $mailObject->Priority = $headers['PRIORITY']; + //$mailObject->Encoding = 'quoted-printable'; // handled by horde + + $mailObject->RFCDateToSet = $headers['DATE']; + $mailObject->Sender = $headers['RETURN-PATH']; + $mailObject->Subject = $headers['SUBJECT']; + $mailObject->MessageID = $headers['MESSAGE-ID']; + // from + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($headers['FROM']):$headers['FROM'])) as $addressObject) { + //debugLog(__METHOD__.__LINE__.'Address to add (FROM):'.array2string($addressObject)); + if (!$addressObject->valid) continue; + $mailObject->From = $addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''); + $mailObject->FromName = $addressObject->personal; +//error_log(__METHOD__.__LINE__.'Address to add (FROM):'.array2string($addressObject)); + } + // to + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($headers['TO']):$headers['TO'])) as $addressObject) { + //debugLog(__METHOD__.__LINE__.'Address to add (TO):'.array2string($addressObject)); + if (!$addressObject->valid) continue; + $mailObject->AddAddress($addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''),$addressObject->personal); + } + // CC + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($headers['CC']):$headers['CC'])) as $addressObject) { + //debugLog(__METHOD__.__LINE__.'Address to add (CC):'.array2string($addressObject)); + if (!$addressObject->valid) continue; + $mailObject->AddCC($addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''),$addressObject->personal); + } + // AddReplyTo + foreach(emailadmin_imapbase::parseAddressList((get_magic_quotes_gpc()?stripslashes($headers['REPLY-TO']):$headers['REPLY-TO'])) as $addressObject) { + //debugLog(__METHOD__.__LINE__.'Address to add (ReplyTO):'.array2string($addressObject)); + if (!$addressObject->valid) continue; + $mailObject->AddReplyTo($addressObject->mailbox. ($addressObject->host ? '@'.$addressObject->host : ''),$addressObject->personal); + } + $Header = $Body = ''; // we do not use Header and Body we use the MailObject + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__." Creation of Mailobject succeeded."); + } + catch (egw_exception_assertion_failed $e) + { + debugLog(__METHOD__.__LINE__." Creation of Mail failed.".$e->getMessage()); + $Header = $Body = ''; + } + + if ($this->debugLevel>0) debugLog("MIME Body -> ".$body); // body is retrieved up + if ($output->nativebodytype==2) { //html + if ($this->debugLevel>0) debugLog("HTML Body with requested pref 4"); + $html = ''. + ''. + ''. + ''. + $css. + ''. + ''. + str_replace("\n","
",str_replace("\r","", str_replace("\r\n","
",$body))). + ''. + ''; + $mailObject->setHtmlBody(str_replace("\n","\r\n", str_replace("\r","",$html)),null,false); + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__." MIME Body (constructed)-> ".$mailObject->findBody('html')->getContents()); + $mailObject->setBody(empty($plainBody)?strip_tags($body):$plainBody); + } + if ($output->nativebodytype==1) { //plain + if ($this->debugLevel>0) debugLog("Plain Body with requested pref 4"); + $mailObject->setBody($plainBody); + } + // we still need the attachments to be added ( if there are any ) + // start handle Attachments + // $_uid, $_partID=null, Horde_Mime_Part $_structure=null, $fetchEmbeddedImages=true, $fetchTextCalendar=false, $resolveTNEF=true, $_folderName='' + $attachments = $this->mail->getMessageAttachments($id, null, null, true, false, true , $_folderName); + if (is_array($attachments) && count($attachments)>0) + { + debugLog(__METHOD__.__LINE__.' gather Attachments for BodyCreation of/for MessageID:'.$id.' found:'.count($attachments)); + //error_log(__METHOD__.__LINE__.array2string($attachments)); + foreach((array)$attachments as $key => $attachment) + { + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' Key:'.$key.'->'.array2string($attachment)); + $attachmentData = ''; + $attachmentData = $this->mail->getAttachment($id, $attachment['partID'],0,false,false,$_folderName); + $mailObject->AddStringAttachment($attachmentData['attachment'], $mailObject->EncodeHeader($attachment['name']), $attachment['mimeType']); + } + } + + $Header = $mailObject->getMessageHeader(); + //debugLog(__METHOD__.__LINE__.' MailObject-Header:'.array2string($Header)); + $Body = trim($mailObject->getMessageBody()); // philip thinks this is needed, so lets try if it does any good/harm + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' MailObject:'.array2string($mailObject)); + if ($this->debugLevel>2) debugLog(__METHOD__.__LINE__." Setting Mailobjectcontent to output:".$Header.self::$LE.self::$LE.$Body); + $output->asbody->data = $Header.self::$LE.self::$LE.$Body; + } + else if (isset($bodypreference[2])) + { + if ($this->debugLevel>0) debugLog("HTML Body with requested pref 2"); + // Send HTML if requested and native type was html + $output->asbody->type = 2; + $htmlbody = ''. + ''. + ''. + ''. + $css. + ''. + ''; + if ($output->nativebodytype==2) + { + // as we fetch html, and body is HTML, we may not need to handle this + $htmlbody .= $body; + } + else + { + // html requested but got only plaintext, so fake html + $htmlbody .= str_replace("\n","
",str_replace("\r","
", str_replace("\r\n","
",$plainBody))); + } + $htmlbody .= ''. + ''; + + if(isset($bodypreference[2]["TruncationSize"]) && strlen($html) > $bodypreference[2]["TruncationSize"]) + { + $htmlbody = utf8_truncate($htmlbody,$bodypreference[2]["TruncationSize"]); + $output->asbody->truncated = 1; + } + $output->asbody->data = $htmlbody; + } + else + { + // Send Plaintext as Fallback or if original body is plainttext + if ($this->debugLevel>0) debugLog("Plaintext Body:".$plainBody); + /* we use plainBody (set above) instead + $bodyStruct = $this->mail->getMessageBody($id,'only_if_no_text'); //'never_display'); + $plain = $this->mail->getdisplayableBody($this->mail,$bodyStruct);//$this->ui->getdisplayableBody($bodyStruct,false); + $plain = html_entity_decode($plain,ENT_QUOTES,$this->mail->detect_encoding($plain)); + $plain = strip_tags($plain); + //$plain = str_replace("\n","\r\n",str_replace("\r","",$plain)); + */ + $output->asbody->type = 1; + if(isset($bodypreference[1]["TruncationSize"]) && + strlen($plainBody) > $bodypreference[1]["TruncationSize"]) + { + $plainBody = utf8_truncate($plainBody, $bodypreference[1]["TruncationSize"]); + $output->asbody->truncated = 1; + } + $output->asbody->data = $plainBody; + } + // In case we have nothing for the body, send at least a blank... + // dw2412 but only in case the body is not rtf! + if ($output->asbody->type != 3 && (!isset($output->asbody->data) || strlen($output->asbody->data) == 0)) + { + $output->asbody->data = " "; + } + // determine estimated datasize for all the above cases ... + $output->asbody->estimateddatasize = strlen($output->asbody->data); + } + // end AS12 Stuff + debugLog(__METHOD__.__LINE__.' gather Header info:'.$headers['SUBJECT'].' from:'.$headers['DATE']); + $output->read = $stat["flags"]; + $output->subject = $this->messages[$id]['subject']; + $output->importance = $this->messages[$id]['priority'] > 3 ? 0 : + ($this->messages[$id]['priority'] < 3 ? 2 : 1) ; + $output->datereceived = $this->mail->_strtotime($headers['DATE'],'ts',true); + $output->displayto = ($headers['TO'] ? $headers['TO']:null); //$stat['FETCHED_HEADER']['to_name'] + // $output->to = $this->messages[$id]['to_address']; //$stat['FETCHED_HEADER']['to_name'] + // $output->from = $this->messages[$id]['sender_address']; //$stat['FETCHED_HEADER']['sender_name'] +//error_log(__METHOD__.__LINE__.' To:'.$headers['TO']); + $output->to = $headers['TO']; +//error_log(__METHOD__.__LINE__.' From:'.$headers['FROM']); + $output->from = $headers['FROM']; + $output->cc = ($headers['CC'] ? $headers['CC']:null); + $output->reply_to = ($headers['REPLY_TO']?$headers['REPLY_TO']:null); + $output->messageclass = "IPM.Note"; + if (stripos($this->messages[$id]['mimetype'],'multipart')!== false && + stripos($this->messages[$id]['mimetype'],'signed')!== false) + { + $output->messageclass = "IPM.Note.SMIME.MultipartSigned"; + } + // start AS12 Stuff + //$output->poommailflag = new SyncPoommailFlag(); + + if ($this->messages[$id]['flagged'] == 1) + { + $output->poommailflag = new SyncPoommailFlag(); + $output->poommailflag->flagstatus = 2; + $output->poommailflag->flagtype = "Flag for Follow up"; + } + + $output->internetcpid = 65001; + $output->contentclass="urn:content-classes:message"; + // end AS12 Stuff + + // start handle Attachments (include text/calendar multipart alternative) + $attachments = $this->mail->getMessageAttachments($id, $_partID='', $_structure=null, $fetchEmbeddedImages=true, $fetchTextCalendar=true, true, $_folderName); + if (is_array($attachments) && count($attachments)>0) + { + debugLog(__METHOD__.__LINE__.' gather Attachments for MessageID:'.$id.' found:'.count($attachments)); + //error_log(__METHOD__.__LINE__.array2string($attachments)); + foreach ($attachments as $key => $attach) + { + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' Key:'.$key.'->'.array2string($attach)); + + // pass meeting requests to calendar plugin + if (strtolower($attach['mimeType']) == 'text/calendar' && strtolower($attach['method']) == 'request' && + isset($GLOBALS['egw_info']['user']['apps']['calendar']) && + ($attachment = $this->mail->getAttachment($id, $attach['partID'],0,false,false,$_folderName)) && + ($output->meetingrequest = calendar_activesync::meetingRequest($attachment['attachment']))) + { + $output->messageclass = "IPM.Schedule.Meeting.Request"; + continue; // do NOT add attachment as attachment + } + if(isset($output->_mapping[SYNC_POOMMAIL_ATTACHMENTS])) { + $attachment = new SyncAttachment(); + } else if(isset($output->_mapping[SYNC_AIRSYNCBASE_ATTACHMENTS])) { + $attachment = new SyncBaseAttachment(); + } + $attachment->attsize = $attach['size']; + $attachment->displayname = $attach['name']; + $attachment->attname = $folderid . ":" . $id . ":" . $attach['partID'];//$key; + //error_log(__METHOD__.__LINE__.'->'.$folderid . ":" . $id . ":" . $attach['partID']); + $attachment->attmethod = 1; + $attachment->attoid = "";//isset($part->headers['content-id']) ? trim($part->headers['content-id']) : ""; + if (!empty($attach['cid']) && $attach['cid'] <> 'NIL' ) + { + $attachment->isinline=true; + $attachment->attmethod=6; + $attachment->contentid= $attach['cid']; + // debugLog("'".$part->headers['content-id']."' ".$attachment->contentid); + $attachment->contenttype = trim($attach['mimeType']); + // debugLog("'".$part->headers['content-type']."' ".$attachment->contentid); + } else { + $attachment->attmethod=1; + } + + if (isset($output->_mapping[SYNC_POOMMAIL_ATTACHMENTS])) { + array_push($output->attachments, $attachment); + } else if(isset($output->_mapping[SYNC_AIRSYNCBASE_ATTACHMENTS])) { + array_push($output->asattachments, $attachment); + } + } + } + //$this->debugLevel=0; + // end handle Attachments + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.array2string($output)); + return $output; + } + return false; + } + + /** + * Process response to meeting request + * + * mail plugin only extracts the iCal attachment and let's calendar plugin deal with adding it + * + * @see BackendDiff::MeetingResponse() + * @param string $folderid folder of meeting request mail + * @param int|string $requestid uid of mail with meeting request + * @param int $response 1=accepted, 2=tentative, 3=decline + * @return int|boolean id of calendar item, false on error + */ + function MeetingResponse($folderid, $requestid, $response) + { + if (!class_exists('calendar_activesync')) + { + debugLog(__METHOD__."(...) no EGroupware calendar installed!"); + return null; + } + if (!($stat = $this->StatMessage($folderid, $requestid))) + { + debugLog(__METHOD__."($requestid, '$folderid', $response) returning FALSE (can NOT stat message)"); + return false; + } + $ret = false; + foreach($this->mail->getMessageAttachments($requestid, $_partID='', $_structure=null, $fetchEmbeddedImages=true, $fetchTextCalendar=true) as $key => $attach) + { + if (strtolower($attach['mimeType']) == 'text/calendar' && strtolower($attach['method']) == 'request' && + ($attachment = $this->mail->getAttachment($requestid, $attach['partID'],0,false))) + { + debugLog(__METHOD__."($requestid, '$folderid', $response) iCal found, calling now backend->MeetingResponse('$attachment[attachment]')"); + + // calling backend again with iCal attachment, to let calendar add the event + if (($ret = $this->backend->MeetingResponse($attachment['attachment'], + $this->backend->createID('calendar',$GLOBALS['egw_info']['user']['account_id']), + $response, $calendarid))) + { + $ret = $calendarid; + } + break; + } + } + debugLog(__METHOD__."($requestid, '$folderid', $response) returning ".array2string($ret)); + return $ret; + } + + /** + * GetAttachmentData + * Should return attachment data for the specified attachment. The passed attachment identifier is + * the exact string that is returned in the 'AttName' property of an SyncAttachment. So, you should + * encode any information you need to find the attachment in that 'attname' property. + * + * @param string $fid - id + * @param string $attname - should contain (folder)id + * @return true, prints the content of the attachment + */ + function GetAttachmentData($fid,$attname) { + debugLog("getAttachmentData: $fid (attname: '$attname')"); + error_log(__METHOD__.__LINE__." Fid: $fid (attname: '$attname')"); + list($folderid, $id, $part) = explode(":", $attname); + + $this->splitID($folderid, $account, $folder); + + if (!isset($this->mail)) $this->mail = mail_bo::getInstance(false,self::$profileID,true,false,true); + + $this->mail->reopen($folder); + $attachment = $this->mail->getAttachment($id,$part,0,false,false,$folder); + print $attachment['attachment']; + unset($attachment); + return true; + } + + /** + * ItemOperationsGetAttachmentData + * Should return attachment data for the specified attachment. The passed attachment identifier is + * the exact string that is returned in the 'AttName' property of an SyncAttachment. So, you should + * encode any information you need to find the attachment in that 'attname' property. + * + * @param string $fid - id + * @param string $attname - should contain (folder)id + * @return SyncAirSyncBaseFileAttachment-object + */ + function ItemOperationsGetAttachmentData($fid,$attname) { + debugLog("ItemOperationsGetAttachmentData: (attname: '$attname')"); + + list($folderid, $id, $part) = explode(":", $attname); + + $this->splitID($folderid, $account, $folder); + + if (!isset($this->mail)) $this->mail = mail_bo::getInstance(false, self::$profileID,true,false,true); + + $this->mail->reopen($folder); + $att = $this->mail->getAttachment($id,$part,0,false,false,$folder); + $attachment = new SyncAirSyncBaseFileAttachment(); + /* + debugLog(__METHOD__.__LINE__.array2string($att)); + if ($arr['filename']=='error.txt' && stripos($arr['attachment'], 'mail_bo::getAttachment failed') !== false) + { + return $attachment; + } + */ + if (is_array($att)) { + $attachment->_data = $att['attachment']; + $attachment->contenttype = trim($att['type']); + } + unset($att); + return $attachment; + } + + /** + * StatMessage should return message stats, analogous to the folder stats (StatFolder). Entries are: + * 'id' => Server unique identifier for the message. Again, try to keep this short (under 20 chars) + * 'flags' => simply '0' for unread, '1' for read + * 'mod' => modification signature. As soon as this signature changes, the item is assumed to be completely + * changed, and will be sent to the PDA as a whole. Normally you can use something like the modification + * time for this field, which will change as soon as the contents have changed. + * + * @param string $folderid + * @param int|array $id event id or array or cal_id:recur_date for virtual exception + * @return array + */ + public function StatMessage($folderid, $id) + { + $messages = $this->fetchMessages($folderid, NULL, (array)$id); + $stat = array_shift($messages); + //debugLog (__METHOD__."('$folderid','$id') returning ".array2string($stat)); + return $stat; + } + + /** + * This function is called when a message has been changed on the PDA. You should parse the new + * message here and save the changes to disk. The return value must be whatever would be returned + * from StatMessage() after the message has been saved. This means that both the 'flags' and the 'mod' + * properties of the StatMessage() item may change via ChangeMessage(). + * Note that this function will never be called on E-mail items as you can't change e-mail items, you + * can only set them as 'read'. + * + * @param string $folderid + * @param int $id for change | empty for create new + * @param SyncMail $message object to SyncObject to create + * @param ContentParameters $contentParameters + */ + function ChangeMessage($folderid, $id, $message, $contentParameters) + { + unset($folderid, $id, $message, $contentParameters); + return false; + } + + /** + * This function is called when the user moves an item on the PDA. You should do whatever is needed + * to move the message on disk. After this call, StatMessage() and GetMessageList() should show the items + * to have a new parent. This means that it will disappear from GetMessageList() will not return the item + * at all on the source folder, and the destination folder will show the new message + * + * @param string $folderid id of the source folder + * @param string $id id of the message + * @param string $newfolderid id of the destination folder + * @param ContentParameters $contentParameters + * + * @return boolean status of the operation + * @throws StatusException could throw specific SYNC_MOVEITEMSSTATUS_* exceptions + */ + public function MoveMessage($folderid, $id, $newfolderid, $contentParameters) + { + unset($contentParameters); // not used, but required by function signature + debugLog("IMAP-MoveMessage: (sfid: '$folderid' id: '$id' dfid: '$newfolderid' )"); + $account = $srcFolder = $destFolder = null; + $this->splitID($folderid, $account, $srcFolder); + $this->splitID($newfolderid, $account, $destFolder); + debugLog("IMAP-MoveMessage: (SourceFolder: '$srcFolder' id: '$id' DestFolder: '$destFolder' )"); + if (!isset($this->mail)) $this->mail = mail_bo::getInstance(false,self::$profileID,true,false,true); + $this->mail->reopen($destFolder); + $status = $this->mail->getFolderStatus($destFolder); + $uidNext = $status['uidnext']; + $this->mail->reopen($srcFolder); + + // move message + $rv = $this->mail->moveMessages($destFolder,(array)$id,true,$srcFolder,true); + debugLog(__METHOD__.__LINE__.array2string($rv)); // this may be true, so try using the nextUID value by examine + // return the new id "as string"" + return ($rv===true ? $uidNext : $rv[$id]) . ""; + } + + /** + * This function is analogous to GetMessageList. + * + * @ToDo loop over available email accounts + */ + public function GetMessageList($folderid, $cutoffdate=NULL) + { + static $cutdate=null; + if (!empty($cutoffdate) && $cutoffdate >0 && (empty($cutdate) || $cutoffdate != $cutdate)) $cutdate = $cutoffdate; + debugLog (__METHOD__.' for Folder:'.$folderid.' SINCE:'.$cutdate.'/'.date("d-M-Y", $cutdate)); + if (empty($cutdate)) + { + $cutdate = egw_time::to('now','ts')-(3600*24*28*3); + debugLog(__METHOD__.' Client set no truncationdate. Using 12 weeks.'.date("d-M-Y", $cutdate)); + } + return $this->fetchMessages($folderid, $cutdate); + } + + private function fetchMessages($folderid, $cutoffdate=NULL, $_id=NULL) + { + if ($this->debugLevel>1) $gstarttime = microtime (true); + //debugLog(__METHOD__.__LINE__); + $rv_messages = array(); + // if the message is still available within the class, we use it instead of fetching it again + if (is_array($_id) && count($_id)==1 && is_array($this->messages) && isset($this->messages[$_id[0]]) && is_array($this->messages[$_id[0]])) + { + //debugLog(__METHOD__.__LINE__." the message ".$_id[0]." is still available within the class, we use it instead of fetching it again"); + $rv_messages = array('header'=>array($this->messages[$_id[0]])); + } + if (empty($rv_messages)) + { + if ($this->debugLevel>1) $starttime = microtime (true); + $this->_connect($this->account); + if ($this->debugLevel>1) + { + $endtime = microtime(true) - $starttime; + debugLog(__METHOD__. " connect took : ".$endtime.' for account:'.$this->account); + } + $messagelist = array(); + // if not connected, any further action must fail + if (!empty($cutoffdate)) $_filter = array('status'=>array('UNDELETED'),'type'=>"SINCE",'string'=> date("d-M-Y", $cutoffdate)); + if ($this->debugLevel>1) $starttime = microtime (true); + $account = $_folderName = $id = null; + $this->splitID($folderid,$account,$_folderName,$id); + if ($this->debugLevel>1) + { + $endtime = microtime(true) - $starttime; + debugLog(__METHOD__. " splitID took : ".$endtime.' for FolderID:'.$folderid); + } + if ($this->debugLevel>1) debugLog(__METHOD__.' for Folder:'.$_folderName.' Filter:'.array2string($_filter).' Ids:'.array2string($_id).'/'.$id); + if ($this->debugLevel>1) $starttime = microtime (true); + $_numberOfMessages = (empty($cutoffdate)?250:99999); + $rv_messages = $this->mail->getHeaders($_folderName, $_startMessage=1, $_numberOfMessages, $_sort=0, $_reverse=false, $_filter, $_id); + if ($this->debugLevel>1) + { + $endtime = microtime(true) - $starttime; + debugLog(__METHOD__. " getHeaders call took : ".$endtime.' for FolderID:'.$_folderName); + } + } + if ($_id == NULL && $this->debugLevel>1) debugLog(__METHOD__." found :". count($rv_messages['header'])); + //debugLog(__METHOD__.__LINE__.' Result:'.array2string($rv_messages)); + foreach ((array)$rv_messages['header'] as $k => $vars) + { + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' ID to process:'.$vars['uid'].' Subject:'.$vars['subject']); + $this->messages[$vars['uid']] = $vars; + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.' MailID:'.$k.'->'.array2string($vars)); + if (!empty($vars['deleted'])) continue; // cut of deleted messages + if ($cutoffdate && $vars['date'] < $cutoffdate) continue; // message is out of range for cutoffdate, ignore it + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.' ID to report:'.$vars['uid'].' Subject:'.$vars['subject']); + $mess["mod"] = $vars['date']; + $mess["id"] = $vars['uid']; + // 'seen' aka 'read' is the only flag we want to know about + $mess["flags"] = 0; + // outlook supports additional flags, set them to 0 + $mess["olflags"] = 0; + if($vars["seen"]) $mess["flags"] = 1; + if($vars["flagged"]) $mess["olflags"] = 2; + if ($this->debugLevel>3) debugLog(__METHOD__.__LINE__.array2string($mess)); + $messagelist[$vars['uid']] = $mess; + unset($mess); + } + + if ($this->debugLevel>1) + { + $endtime = microtime(true) - $gstarttime; + debugLog(__METHOD__. " total time used : ".$endtime.' for Folder:'.$_folderName.' Filter:'.array2string($_filter).' Ids:'.array2string($_id).'/'.$id); + } + return $messagelist; + } + + /** + * Search mailbox for a given pattern + * + * @param string $searchquery + * @return array with just rows (no values for keys rows, status or global_search_status!) + */ + public function getSearchResultsMailbox($searchquery) + { + if (!is_array($searchquery)) return array(); + if ($this->debugLevel>0) debugLog(__METHOD__.__LINE__.array2string($searchquery)); + // 19.10.2011 16:28:59 [24502] mail_activesync::getSearchResultsMailbox1408 + //Array( + // [query] => Array( + // [0] => Array([op] => Search:And + // [value] => Array( + // [FolderType] => Email + // [FolderId] => 101000000000 + // [Search:FreeText] => ttt + // [subquery] => Array( + // [0] => Array([op] => Search:GreaterThan + // [value] => Array( + // [POOMMAIL:DateReceived] => 1318975200)) + // [1] => Array([op] => Search:LessThan + // [value] => Array( + // [POOMMAIL:DateReceived] => 1319034600)))))) + // [rebuildresults] => 1 + // [deeptraversal] => + // [range] => 0-999) + if (isset($searchquery['rebuildresults'])) { + $rebuildresults = $searchquery['rebuildresults']; + } else { + $rebuildresults = false; + } + if ($this->debugLevel>0) debugLog( 'RebuildResults ['.$rebuildresults.']' ); + + if (isset($searchquery['deeptraversal'])) { + $deeptraversal = $searchquery['deeptraversal']; + } else { + $deeptraversal = false; + } + if ($this->debugLevel>0) debugLog( 'DeepTraversal ['.$deeptraversal.']' ); + + if (isset($searchquery['range'])) { + $range = explode("-",$searchquery['range']); + $limit = $range[1] - $range[0] + 1; + } else { + $range = false; + } + if ($this->debugLevel>0) debugLog( 'Range ['.print_r($range, true).']' ); + + //foreach($searchquery['query'] as $k => $value) { + // $query = $value; + //} + if (isset($searchquery['query'][0]['value']['FolderId'])) + { + $folderid = $searchquery['query'][0]['value']['FolderId']; + } + // other types may be possible - we support quicksearch first (freeText in subject and from (or TO in Sent Folder)) + if (is_null(emailadmin_imapbase::$supportsORinQuery) || !isset(emailadmin_imapbase::$supportsORinQuery[$this->mail->profileID])) + { + emailadmin_imapbase::$supportsORinQuery = egw_cache::getCache(egw_cache::INSTANCE,'email','supportsORinQuery'.trim($GLOBALS['egw_info']['user']['account_id']),$callback=null,$callback_params=array(),$expiration=60*60*10); + if (!isset(emailadmin_imapbase::$supportsORinQuery[$this->mail->profileID])) emailadmin_imapbase::$supportsORinQuery[$this->mail->profileID]=true; + } + + if (isset($searchquery['query'][0]['value']['Search:FreeText'])) + { + $type = (emailadmin_imapbase::$supportsORinQuery[$this->mail->profileID]?'quick':'subject'); + $searchText = $searchquery['query'][0]['value']['Search:FreeText']; + } + if (!$folderid) + { + $_folderName = ($this->mail->sessionData['mailbox']?$this->mail->sessionData['mailbox']:'INBOX'); + $folderid = $this->createID($account=0,$_folderName); + } +//if ($searchquery['query'][0]['value'][subquery][0][op]=='Search:GreaterThan'); +//if (isset($searchquery['query'][0]['value'][subquery][0][value][POOMMAIL:DateReceived])); +//if ($searchquery['query'][0]['value'][subquery][1][op]=='Search:LessThan'); +//if (isset($searchquery['query'][0]['value'][subquery][1][value][POOMMAIL:DateReceived])); +//$_filter = array('status'=>array('UNDELETED'),'type'=>"SINCE",'string'=> date("d-M-Y", $cutoffdate)); + $rv = $this->splitID($folderid,$account,$_folderName,$id); + $this->_connect($account); + $_filter = array('type'=> (emailadmin_imapbase::$supportsORinQuery[$this->mail->profileID]?'quick':'subject'), + 'string'=> $searchText, + 'status'=>'any', + ); + + //$_filter[] = array('type'=>"SINCE",'string'=> date("d-M-Y", $cutoffdate)); + if ($this->debugLevel>1) debugLog (__METHOD__.' for Folder:'.$_folderName.' Filter:'.array2string($_filter)); + $rv_messages = $this->mail->getHeaders($_folderName, $_startMessage=1, $_numberOfMessages=($limit?$limit:9999999), $_sort=0, $_reverse=false, $_filter, $_id=NULL); + //debugLog(__METHOD__.__LINE__.array2string($rv_messages)); + $list=array(); + foreach((array)$rv_messages['header'] as $i => $vars) + { + $list[] = array( + "uniqueid" => $folderid.':'.$vars['uid'], + "item" => $vars['uid'], + //"parent" => ???, + "searchfolderid" => $folderid, + ); + } + //error_log(__METHOD__.__LINE__.array2string($list)); + //debugLog(__METHOD__.__LINE__.array2string($list)); + return $list;//array('rows'=>$list,'status'=>1,'global_search_status'=>1);//array(); + } + + /** + * Get ID of parent Folder or '0' for folders in root + * + * @param int $account + * @param string $folder + * @return string + */ + private function getParentID($account,$folder) + { + $this->_connect($account); + if (!isset($this->folders)) $this->folders = $this->mail->getFolderObjects(true,false); + + $mailFolder = $this->folders[$folder]; + if (!isset($mailFolder)) return false; + $delimiter = (isset($mailFolder->delimiter)?$mailFolder->delimiter:$this->mail->getHierarchyDelimiter()); + $parent = explode($delimiter,$folder); + array_pop($parent); + $parent = implode($delimiter,$parent); + + $id = $parent ? $this->createID($account, $parent) : '0'; + if ($this->debugLevel>1) debugLog(__METHOD__."('$folder') --> parent=$parent --> $id"); + return $id; + } + + /** + * Get Information about a folder + * + * @param string $id + * @return SyncFolder|boolean false on error + */ + public function GetFolder($id) + { + static $last_id = null; + static $folderObj = null; + if (isset($last_id) && $last_id === $id) return $folderObj; + + try { + $account = $folder = null; + $this->splitID($id, $account, $folder); + } + catch(Exception $e) { + debugLog(__METHOD__.__LINE__.' failed for '.$e->getMessage()); + return $folderObj=false; + } + $this->_connect($account); + if (!isset($this->folders)) $this->folders = $this->mail->getFolderObjects(true,false); + + $mailFolder = $this->folders[$folder]; + if (!isset($mailFolder)) return $folderObj=false; + + $folderObj = new SyncFolder(); + $folderObj->serverid = $id; + $folderObj->parentid = $this->getParentID($account,$folder); + $folderObj->displayname = $mailFolder->shortDisplayName; + if ($this->debugLevel>1) debugLog(__METHOD__.__LINE__." ID: $id, Account:$account, Folder:$folder"); + // get folder-type + foreach($this->folders as $inbox => $mailFolder) break; + if ($folder == $inbox) + { + $folderObj->type = SYNC_FOLDER_TYPE_INBOX; + } + elseif($this->mail->isDraftFolder($folder, false)) + { + //debugLog(__METHOD__.' isDraft'); + $folderObj->type = SYNC_FOLDER_TYPE_DRAFTS; + $folderObj->parentid = 0; // required by devices + } + elseif($this->mail->isTrashFolder($folder, false)) + { + $folderObj->type = SYNC_FOLDER_TYPE_WASTEBASKET; + $this->_wasteID = $folder; + //error_log(__METHOD__.__LINE__.' TrashFolder:'.$this->_wasteID); + $folderObj->parentid = 0; // required by devices + } + elseif($this->mail->isSentFolder($folder, false)) + { + $folderObj->type = SYNC_FOLDER_TYPE_SENTMAIL; + $folderObj->parentid = 0; // required by devices + $this->_sentID = $folder; + //error_log(__METHOD__.__LINE__.' SentFolder:'.$this->_sentID); + } + elseif($this->mail->isOutbox($folder, false)) + { + //debugLog(__METHOD__.' isOutbox'); + $folderObj->type = SYNC_FOLDER_TYPE_OUTBOX; + $folderObj->parentid = 0; // required by devices + } + else + { + //debugLog(__METHOD__.' isOther Folder'.$folder); + $folderObj->type = SYNC_FOLDER_TYPE_USER_MAIL; + } + + if ($this->debugLevel>1) debugLog(__METHOD__."($id) --> $folder --> type=$folderObj->type, parentID=$folderObj->parentid, displayname=$folderObj->displayname"); + return $folderObj; + } + + /** + * Return folder stats. This means you must return an associative array with the + * following properties: + * + * "id" => The server ID that will be used to identify the folder. It must be unique, and not too long + * How long exactly is not known, but try keeping it under 20 chars or so. It must be a string. + * "parent" => The server ID of the parent of the folder. Same restrictions as 'id' apply. + * "mod" => This is the modification signature. It is any arbitrary string which is constant as long as + * the folder has not changed. In practice this means that 'mod' can be equal to the folder name + * as this is the only thing that ever changes in folders. (the type is normally constant) + * + * @return array with values for keys 'id', 'mod' and 'parent' + */ + public function StatFolder($id) + { + $folder = $this->GetFolder($id); + + $stat = array( + 'id' => $id, + 'mod' => $folder->displayname, + 'parent' => $folder->parentid, + ); + + return $stat; + } + + + /** + * Return a changes array + * + * if changes occurr default diff engine computes the actual changes + * + * @param string $folderid + * @param string &$syncstate on call old syncstate, on return new syncstate + * @return array|boolean false if $folderid not found, array() if no changes or array(array("type" => "fakeChange")) + */ + function AlterPingChanges($folderid, &$syncstate) + { + debugLog(__METHOD__.' called with '.$folderid); + $account = $folder = null; + $this->splitID($folderid, $account, $folder); + if (is_numeric($account)) $type = 'mail'; + if ($type != 'mail') return false; + + if (!isset($this->mail)) $this->mail = mail_bo::getInstance(false,self::$profileID,true,false,true); + + $changes = array(); + debugLog("AlterPingChanges on $folderid ($folder) stat: ". $syncstate); + $this->mail->reopen($folder); + + $status = $this->mail->getFolderStatus($folder,$ignoreStatusCache=true); + if (!$status) { + debugLog("AlterPingChanges: could not stat folder $folder "); + return false; + } else { + $newstate = "M:". $status['messages'] ."-R:". $status['recent'] ."-U:". $status['unseen']."-NUID:".$status['uidnext']."-UIDV:".$status['uidvalidity']; + + // message number is different - change occured + if ($syncstate != $newstate) { + $syncstate = $newstate; + debugLog("AlterPingChanges: Change FOUND!"); + // build a dummy change + $changes = array(array("type" => "fakeChange")); + } + } + //error_log(__METHOD__."('$folderid','$syncstate_was') syncstate='$syncstate' returning ".array2string($changes)); + return $changes; + } + + /** + * Should return a wastebasket folder if there is one. This is used when deleting + * items; if this function returns a valid folder ID, then all deletes are handled + * as moves and are sent to your backend as a move. If it returns FALSE, then deletes + * are always handled as real deletes and will be sent to your importer as a DELETE + */ + function GetWasteBasket() + { + debugLog(__METHOD__.__LINE__.' called.'); + $this->_connect($this->account); + return $this->_wasteID; + } + + /** + * Called when the user has requested to delete (really delete) a message. Usually + * this means just unlinking the file its in or somesuch. After this call has succeeded, a call to + * GetMessageList() should no longer list the message. If it does, the message will be re-sent to the mobile + * as it will be seen as a 'new' item. This means that if this method is not implemented, it's possible to + * delete messages on the PDA, but as soon as a sync is done, the item will be resynched to the mobile + * + * @param string $folderid id of the folder + * @param string $id id of the message + * @param ContentParameters $contentParameters + * + * @access public + * @return boolean status of the operation + * @throws StatusException could throw specific SYNC_STATUS_* exceptions + */ + public function DeleteMessage($folderid, $id, $contentParameters) + { + unset($contentParameters); // not used, but required by function signature + debugLog("IMAP-DeleteMessage: (fid: '$folderid' id: '$id' )"); + /* + $this->imap_reopenFolder($folderid); + $s1 = @imap_delete ($this->_mbox, $id, FT_UID); + $s11 = @imap_setflag_full($this->_mbox, $id, "\\Deleted", FT_UID); + $s2 = @imap_expunge($this->_mbox); + */ + // we may have to split folderid + $account = $folder = null; + $this->splitID($folderid, $account, $folder); + debugLog(__METHOD__.__LINE__.' '.$folderid.'->'.$folder); + $_messageUID = (array)$id; + + $this->_connect($this->account); + $this->mail->reopen($folder); + try + { + $rv = $this->mail->deleteMessages($_messageUID, $folder); + } + catch (egw_exception $e) + { + $error = $e->getMessage(); + debugLog(__METHOD__.__LINE__." $_messageUID, $folder ->".$error); + // if the server thinks the message does not exist report deletion as success + if (stripos($error,'[NONEXISTENT]')!==false) return true; + return false; + } + + // this may be a bit rude, it may be sufficient that GetMessageList does not list messages flagged as deleted + if ($this->mail->mailPreferences['deleteOptions'] == 'mark_as_deleted') + { + // ignore mark as deleted -> Expunge! + //$this->mail->icServer->expunge(); // do not expunge as GetMessageList does not List messages flagged as deleted + } + debugLog("IMAP-DeleteMessage: $rv"); + + return $rv; + } + + /** + * Changes the 'read' flag of a message on disk. The $flags + * parameter can only be '1' (read) or '0' (unread). After a call to + * SetReadFlag(), GetMessageList() should return the message with the + * new 'flags' but should not modify the 'mod' parameter. If you do + * change 'mod', simply setting the message to 'read' on the mobile will trigger + * a full resync of the item from the server. + * + * @param string $folderid id of the folder + * @param string $id id of the message + * @param int $flags read flag of the message + * @param ContentParameters $contentParameters + * + * @access public + * @return boolean status of the operation + * @throws StatusException could throw specific SYNC_STATUS_* exceptions + */ + public function SetReadFlag($folderid, $id, $flags, $contentParameters) + { + unset($contentParameters); // not used, but required by function signature + // debugLog("IMAP-SetReadFlag: (fid: '$folderid' id: '$id' flags: '$flags' )"); + $account = $folder = null; + $this->splitID($folderid, $account, $folder); + + $_messageUID = (array)$id; + $this->_connect($this->account); + $rv = $this->mail->flagMessages((($flags) ? "read" : "unread"), $_messageUID,$folder); + debugLog("IMAP-SetReadFlag -> set ".array2string($_messageUID).' in Folder '.$folder." as " . (($flags) ? "read" : "unread") . "-->". $rv); + + return $rv; + } + + /** + * Creates or modifies a folder + * + * @param string $id of the parent folder + * @param string $oldid => if empty -> new folder created, else folder is to be renamed + * @param string $displayname => new folder name (to be created, or to be renamed to) + * @param string $type folder type, ignored in IMAP + * + * @return array|boolean stat array or false on error + */ + public function ChangeFolder($id, $oldid, $displayname, $type) + { + debugLog(__METHOD__."('$id', '$oldid', '$displayname', $type) NOT supported!"); + return false; + } + + /** + * Deletes (really delete) a Folder + * + * @param string $parentid of the folder to delete + * @param string $id of the folder to delete + * + * @return + * @TODO check what is to be returned + */ + public function DeleteFolder($parentid, $id) + { + debugLog(__METHOD__."('$parentid', '$id') NOT supported!"); + return false; + } + + /** + * modify olflags (outlook style) flag of a message + * + * @param $folderid + * @param $id + * @param $flags + * + * + * @DESC The $flags parameter must contains the poommailflag Object + */ + function ChangeMessageFlag($folderid, $id, $flags) + { + $_messageUID = (array)$id; + $this->_connect($this->account); + $account = $folder = null; + $this->splitID($folderid, $account, $folder); + $rv = $this->mail->flagMessages((($flags->flagstatus == 2) ? "flagged" : "unflagged"), $_messageUID,$folder); + debugLog("IMAP-SetFlaggedFlag -> set ".array2string($_messageUID).' in Folder '.$folder." as " . (($flags->flagstatus == 2) ? "flagged" : "unflagged") . "-->". $rv); + + return $rv; + } + + /** + * Create a max. 32 hex letter ID, current 20 chars are used + * + * @param int $account mail account id + * @param string $folder + * @param int $id =0 + * @return string + * @throws egw_exception_wrong_parameter + */ + private function createID($account,$folder,$id=0) + { + if (!is_numeric($folder)) + { + // convert string $folder in numeric id + $folder = $this->folder2hash($account,$f=$folder); + } + + $str = $this->backend->createID($account, $folder, $id); + + if ($this->debugLevel>1) debugLog(__METHOD__."($account,'$f',$id) type=$account, folder=$folder --> '$str'"); + + return $str; + } + + /** + * Split an ID string into $app, $folder and $id + * + * @param string $str + * @param int &$account mail account id + * @param string &$folder + * @param int &$id=null + * @throws egw_exception_wrong_parameter + */ + private function splitID($str,&$account,&$folder,&$id=null) + { + $this->backend->splitID($str, $account, $folder, $id); + + // convert numeric folder-id back to folder name + $folder = $this->hash2folder($account,$f=$folder); + + if ($this->debugLevel>1) debugLog(__METHOD__."('$str','$account','$folder',$id)"); + } + + /** + * Methods to convert (hierarchical) folder names to nummerical id's + * + * This is currently done by storing a serialized array in the device specific + * state directory. + */ + + /** + * Convert folder string to nummeric hash + * + * @param int $account + * @param string $folder + * @return int + */ + private function folder2hash($account,$folder) + { + if(!isset($this->folderHashes)) $this->readFolderHashes(); + + if (($index = array_search($folder, (array)$this->folderHashes[$account])) === false) + { + // new hash + $this->folderHashes[$account][] = $folder; + $index = array_search($folder, (array)$this->folderHashes[$account]); + + // maybe later storing in on class destruction only + $this->storeFolderHashes(); + } + return $index; + } + + /** + * Convert numeric hash to folder string + * + * @param int $account + * @param int $index + * @return string NULL if not used so far + */ + private function hash2folder($account,$index) + { + if(!isset($this->folderHashes)) $this->readFolderHashes(); + + return $this->folderHashes[$account][$index]; + } + + private $folderHashes; + + /** + * Read hashfile from state dir + */ + private function readFolderHashes() + { + if (file_exists($file = $this->hashFile()) && + ($hashes = file_get_contents($file))) + { + $this->folderHashes = json_decode($hashes,true); + // fallback in case hashes have been serialized instead of being json-encoded + if (json_last_error()!=JSON_ERROR_NONE) + { + //error_log(__METHOD__.__LINE__." error decoding with json"); + $this->folderHashes = unserialize($hashes); + } + } + else + { + $this->folderHashes = array(); + } + } + + /** + * Store hashfile in state dir + * + * return int|boolean false on error + */ + private function storeFolderHashes() + { + // make sure $this->folderHashes is an array otherwise json_encode may fail on decode for string,integer,float or boolean + return file_put_contents($this->hashFile(), json_encode((is_array($this->folderHashes)?$this->folderHashes:array($this->folderHashes)))); + } + + /** + * Get name of hashfile in state dir + * + * @throws egw_exception_assertion_failed + */ + private function hashFile() + { + if (!($dev_id=Request::GetDeviceID())) + { + throw new egw_exception_assertion_failed(__METHOD__."() called without this->_devid set!"); + } + if (!file_exists(STATE_DIR.$dev_id)) + { + mkdir(STATE_DIR.$dev_id); + } + return STATE_DIR.$dev_id.'/'.$dev_id.'.hashes'; + } + +}