1<?php 2/*********************************************** 3* File : replybackimexporter.php 4* Project : Z-Push 5* Descr : This class fullfills the IImportChanges 6* and IExportChanges interfaces. 7* Messages that are imported are silently 8* ignored and then exported again. 9* 10* Created : 22.04.2016 11* 12* Copyright 2016 Zarafa Deutschland GmbH 13* 14* This program is free software: you can redistribute it and/or modify 15* it under the terms of the GNU Affero General Public License, version 3, 16* as published by the Free Software Foundation. 17* 18* This program is distributed in the hope that it will be useful, 19* but WITHOUT ANY WARRANTY; without even the implied warranty of 20* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21* GNU Affero General Public License for more details. 22* 23* You should have received a copy of the GNU Affero General Public License 24* along with this program. If not, see <http://www.gnu.org/licenses/>. 25* 26* Consult LICENSE file for details 27************************************************/ 28 29class ReplyBackImExporter implements IImportChanges, IExportChanges { 30 const REPLYBACKID = "ReplyBack"; 31 const EXPORT_DELETE_AFTER_MOVE_TIMES = 3; 32 const CHANGE = 1; 33 const DELETION = 2; 34 const READFLAG = 3; 35 const CREATION = 4; 36 const MOVEDHERE = 5; 37 38 private $session; 39 private $store; 40 private $folderid; 41 private $changes; 42 private $changesDest; 43 private $changesNext; 44 private $step; 45 private $exportImporter; 46 private $mapiprovider; 47 private $contentparameters; 48 private $moveSrcState; 49 private $moveDstState; 50 51 /** 52 * Constructor 53 * 54 * @param mapisession $session 55 * @param mapistore $store 56 * @param string $folderid 57 * 58 * @access public 59 * @throws StatusException 60 */ 61 public function __construct($session, $store, $folderid) { 62 $this->session = $session; 63 $this->store = $store; 64 $this->folderid = $folderid; 65 66 $this->changes = array(); 67 $this->step = 0; 68 69 $this->changesDest = array(); 70 $this->changesNext = array(); 71 $this->mapiprovider = new MAPIProvider($this->session, $this->store); 72 $this->moveSrcState = false; 73 $this->moveDstState = false; 74 } 75 76 /** 77 * Initializes the state and flags. 78 * 79 * @param string $state 80 * @param int $flags 81 * 82 * @access public 83 * @return boolean status flag 84 * @throws StatusException 85 */ 86 public function Config($state, $flags = 0) { 87 if (is_array($state)) { 88 $this->changes = array_merge($this->changes, $state); 89 } 90 $this->step = 0; 91 return true; 92 } 93 94 /** 95 * Configures additional parameters used for content synchronization. 96 * 97 * @param ContentParameters $contentparameters 98 * 99 * @access public 100 * @return boolean 101 * @throws StatusException 102 */ 103 public function ConfigContentParameters($contentparameters) { 104 $this->contentparameters = $contentparameters; 105 return true; 106 } 107 108 /** 109 * Reads and returns the current state. 110 * 111 * @access public 112 * @return string 113 */ 114 public function GetState() { 115 // we can discard all entries in the $changes array up to $step 116 $changes = array_slice($this->changes, $this->step); 117 return array_merge($changes, $this->changesNext); 118 } 119 120 /** 121 * Sets the states from move operations. 122 * When src and dst state are set, a MOVE operation is being executed. 123 * 124 * @param mixed $srcState 125 * @param mixed (opt) $dstState, default: null 126 * 127 * @access public 128 * @return boolean 129 */ 130 public function SetMoveStates($srcState, $dstState = null) { 131 if (is_array($srcState)) { 132 $this->changes = array_merge($this->changes, $srcState); 133 } 134 if (is_array($dstState)) { 135 $this->changesDest = array_merge($this->changes, $dstState); 136 } 137 return true; 138 } 139 140 /** 141 * Gets the states of special move operations. 142 * 143 * @access public 144 * @return array(0 => $srcState, 1 => $dstState) 145 */ 146 public function GetMoveStates() { 147 // if a move was executed, there will be changes for the destination folder, so we have to return the 148 // source changes as well. If not, they will be transported via GetState(). 149 $srcMoveState = false; 150 $dstMoveState = $this->changesDest; 151 if (!empty($this->changesDest)) { 152 $srcMoveState = $this->changes; 153 } 154 else { 155 $dstMoveState = false; 156 } 157 return array($srcMoveState, $dstMoveState); 158 } 159 160 161 /** 162 * Implement interfaces which are never used. 163 */ 164 165 /** 166 * Loads objects which are expected to be exported with the state. 167 * Before importing/saving the actual message from the mobile, a conflict detection should be done. 168 * 169 * @param ContentParameters $contentparameters 170 * @param string $state 171 * 172 * @access public 173 * @return boolean 174 * @throws StatusException 175 */ 176 public function LoadConflicts($contentparameters, $state) { 177 return true; 178 } 179 180 /** 181 * Imports a move of a message. This occurs when a user moves an item to another folder. 182 * 183 * @param string $id 184 * @param string $newfolder destination folder 185 * 186 * @access public 187 * @return boolean 188 * @throws StatusException 189 */ 190 public function ImportMessageMove($id, $newfolder) { 191 if (strtolower($newfolder) == strtolower(bin2hex($this->folderid)) ) 192 throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, source and destination are equal", $id, $newfolder), SYNC_MOVEITEMSSTATUS_SAMESOURCEANDDEST); 193 194 // At this point, we don't know which case of move is happening: 195 // 1. ReadOnly -> Writeable (should normally work, message is duplicated) 196 // 2. ReadOnly -> ReadOnly 197 // 3. Writeable -> ReadOnly 198 // As we don't know which case happens, we do the same for all cases (no move, no duplication!): 199 // 1. in the src folder, the message is added again (same case as a deletion in RO) 200 // 2. generate a tmp-id for the destination message in the destination folder 201 // 3. for the destination folder, the tmp-id message is deleted (same as creation in RO) 202 203 // make sure the message is added again to the src folder 204 $this->changes[] = array(self::DELETION, $id, null); 205 206 // generate tmp-id and have it removed later via the dest changes (saved via DstMoveState) 207 $tmpId = $this->getTmpId($newfolder); 208 $this->changesDest[] = array(self::MOVEDHERE, $tmpId, 0); 209 ZLog::Write(LOGLEVEL_DEBUG, sprintf("ReplyBackImExporter->ImportMessageMove(): Move forbidden. Restoring message in source folder and added a delete request for the destination folder for the id: %s", $tmpId)); 210 211 return $tmpId; 212 } 213 214 /** 215 * Imports a change on a folder. 216 * 217 * @param object $folder SyncFolder 218 * 219 * @access public 220 * @return boolean/SyncObject status/object with the ath least the serverid of the folder set 221 * @throws StatusException 222 */ 223 public function ImportFolderChange($folder) { 224 return false; 225 } 226 227 /** 228 * Imports a folder deletion. 229 * 230 * @param SyncFolder $folder at least "serverid" needs to be set 231 * 232 * @access public 233 * @return boolean/int success/SYNC_FOLDERHIERARCHY_STATUS 234 * @throws StatusException 235 */ 236 public function ImportFolderDeletion($folder) { 237 return false; 238 } 239 240 241 /**---------------------------------------------------------------------------------------------------------- 242 * IImportChanges 243 */ 244 245 /** 246 * Imports a message change, which is imported into memory. 247 * 248 * @param string $id id of message which is changed 249 * @param SyncObject $message message to be changed 250 * 251 * @access public 252 * @return boolean 253 */ 254 public function ImportMessageChange($id, $message) { 255 if(ZPush::GetDeviceManager()->IsKoe()) { 256 // Ignore incoming update events of KOE caused by PatchItem - ZP-1060 257 if (KOE_CAPABILITY_NOTES && $id && $message instanceof SyncNote && !isset($message->asbody)) { 258 ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->ImportMessageChange(): KOE patch item update. Ignoring incoming update."); 259 return true; 260 } 261 // KOE ZP-990: OL updates the deleted category which causes a race condition if more than one KOE is connected to that user 262 if (KOE_CAPABILITY_RECEIVEFLAGS && $message instanceof SyncMail && !isset($message->flag) && isset($message->categories)) { 263 // check if the categories changed 264 $serverMessage = $this->getMessage($id, false); 265 if((empty($message->categories) && empty($serverMessage->categories)) || 266 (is_array($mapiCategories) && count(array_diff($mapiCategories, $message->categories)) == 0 && count(array_diff($message->categories, $mapiCategories)) == 0)) { 267 ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->ImportMessageChange(): KOE update of flag categories. Ignoring incoming update."); 268 return true; 269 } 270 } 271 } 272 $hexFolderid = bin2hex($this->folderid); 273 // data is going to be dropped, inform the user 274 if (@constant('READ_ONLY_NOTIFY_LOST_DATA')) { 275 $notifyUser = true; 276 277 278 $userFolder = ZPush::GetDeviceManager()->GetAdditionalUserSyncFolder($hexFolderid); 279 if ($userFolder['flags'] & DeviceManager::FLD_FLAGS_NOREADONLYNOTIFY) { 280 ZLog::Write(LOGLEVEL_INFO, "ReplyBackImExporter->ImportMessageChange(): the folder has no notify flag. Data received from the mobile will be lost. User was *not* informed as configured (see FLD_FLAGS_NOREADONLYNOTIFY)"); 281 $notifyUser = false; 282 } 283 elseif (@constant('READ_ONLY_NONOTIFY')) { 284 $noNotifyFolders = explode(',', READ_ONLY_NONOTIFY); 285 foreach ($noNotifyFolders as $noNotifyFolder) { 286 if (strcasecmp(trim($noNotifyFolder), $hexFolderid) == 0) { 287 ZLog::Write(LOGLEVEL_INFO, "ReplyBackImExporter->ImportMessageChange(): the folder is in no notify list. Data received from the mobile will be lost. User was *not* informed as configured (see READ_ONLY_NONOTIFY)"); 288 $notifyUser = false; 289 break; 290 } 291 } 292 } 293 294 if ($notifyUser) { 295 try { 296 // get the old message - if there is no old message, this is a "create" action 297 $oldmessage = $this->getMessage($id, false); 298 if (!$oldmessage instanceof SyncObject) { 299 $oldmessage = $message; 300 } 301 302 ZLog::Write(LOGLEVEL_DEBUG, sprintf("ReplyBackImExporter->ImportMessageChange(): Data send from the mobile will be lost. Sending email to user notifying about this.")); 303 $this->sendNotificationEmail($message, $oldmessage); 304 } 305 catch (ZPushException $zpe) { 306 // TODO should we still print the email to the log so the data is not lost at all? 307 ZLog::Write(LOGLEVEL_ERROR, "ReplyBackImExporter->ImportMessageChange(): exception sending notification email"); 308 } 309 } 310 } 311 else { 312 ZLog::Write(LOGLEVEL_INFO, sprintf("ReplyBackImExporter->ImportMessageChange(): Data received from the mobile will be lost. User was *not* informed as configured (see READ_ONLY_NOTIFY_LOST_DATA).")); 313 } 314 315 if ($id) { 316 $this->changes[] = array(self::CHANGE, $id, $message); 317 return true; 318 } 319 // if there is no $id it means it's a new object. We have to reply back that we accepted it and then delete it. 320 $id = $this->getTmpId($hexFolderid); 321 $this->changes[] = array(self::CREATION, $id, $message); 322 return $id; 323 } 324 325 /** 326 * Imports a deletion. This may conflict if the local object has been modified. 327 * 328 * @param string $id 329 * @param boolean $asSoftDelete (opt) if true, the deletion is exported as "SoftDelete", else as "Remove" - default: false 330 * 331 * @access public 332 * @return boolean 333 */ 334 public function ImportMessageDeletion($id, $asSoftDelete = false) { 335 // TODO do something different due to $asSoftDelete? 336 $this->changes[] = array(self::DELETION, $id, null); 337 throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageDeletion('%s'): Read only folder. Data from PIM will be dropped! Server will read data.", $id), SYNC_STATUS_CONFLICTCLIENTSERVEROBJECT, null, LOGLEVEL_INFO); 338 } 339 340 /** 341 * Imports a change in 'read' flag. 342 * This can never conflict. 343 * 344 * @param string $id 345 * @param int $flags 346 * @param array $categories 347 * 348 * 349 * @access public 350 * @return boolean 351 * @throws StatusException 352 */ 353 public function ImportMessageReadFlag($id, $flags, $categories = array()) { 354 $this->changes[] = array(self::READFLAG, $id, $flags); 355 return true; 356 } 357 358 359 /**---------------------------------------------------------------------------------------------------------- 360 * IExportChanges & destination importer 361 */ 362 363 /** 364 * Initializes the Exporter where changes are synchronized to. 365 * 366 * @param IImportChanges $importer 367 * 368 * @access public 369 * @return boolean 370 */ 371 public function InitializeExporter(&$importer) { 372 $this->exportImporter = $importer; 373 $this->step = 0; 374 return true; 375 } 376 377 /** 378 * Returns the amount of changes to be exported. 379 * 380 * @access public 381 * @return int 382 */ 383 public function GetChangeCount() { 384 return count($this->changes); 385 } 386 387 /** 388 * Synchronizes a change. The previously imported messages are now retrieved from the backend 389 * and sent back to the mobile. 390 * 391 * @access public 392 * @return array 393 */ 394 public function Synchronize() { 395 if($this->step < count($this->changes) && isset($this->exportImporter)) { 396 397 $change = $this->changes[$this->step]; 398 399 $this->step++; 400 $status = array("steps" => count($this->changes), "progress" => $this->step); 401 402 $id = $change[1]; 403 $oldmessage = $change[2]; 404 405 406 // MOVEDHERE is an OL hack: export the deletion of the destination folder 407 // several times, because OL doesn't removes the item the first time 408 // we generate the same change again for EXPORT_DELETE_AFTER_MOVE_TIMES. 409 if ($change[0] == self::MOVEDHERE) { 410 $this->exportImporter->ImportMessageDeletion($id); 411 if (is_int($oldmessage) && $oldmessage < self::EXPORT_DELETE_AFTER_MOVE_TIMES) { 412 $change[2]++; 413 $this->changesNext[] = $change; 414 } 415 } 416 else if ($change[0] === self::CREATION || $this->isTmpId($id)) { 417 $this->exportImporter->ImportMessageDeletion($id); 418 } 419 else { 420 // This block also handles the read flags, 421 // so that the todo flags are exported properly as well. 422 // get the server side message 423 $message = $this->getMessage($id); 424 if (! $message instanceOf SyncObject) { 425 return $message; 426 } 427 428 if ($change[0] === self::DELETION) { 429 $message->flags = SYNC_NEWMESSAGE; 430 } 431 else { 432 $message->flags = ""; 433 } 434 // only reply back on modify 435 if ($change[1] !== "") { 436 $this->exportImporter->ImportMessageChange($id, $message); 437 } 438 } 439 440 // return progress array 441 return $status; 442 } 443 else 444 return false; 445 } 446 447 /** 448 * Generates a temporary id. 449 * 450 * @param string $backendfolderid 451 * 452 * @access private 453 * @return string 454 */ 455 private function getTmpId($backendfolderid) { 456 return ZPush::GetDeviceManager()->GetFolderIdForBackendId($backendfolderid) .":". self::REPLYBACKID ."". substr(md5(microtime()), 0, 5); 457 } 458 459 /** 460 * Checks if an id is a temporary id generated by the ReplyBackImExporter. 461 * 462 * @access public 463 * @return boolean 464 */ 465 private function isTmpId($id) { 466 return !!stripos($id, self::REPLYBACKID); 467 } 468 469 private function getMessage($id, $announceErrors = true) { 470 if (!$id) { 471 return false; 472 } 473 $message = false; 474 475 list($fsk, $sk) = Utils::SplitMessageId($id); 476 477 $sourcekey = hex2bin($sk); 478 $parentsourcekey = hex2bin(ZPush::GetDeviceManager()->GetBackendIdForFolderId($fsk)); 479 // Backwards compatibility for old style folder ids 480 if (empty($parentsourcekey)) { 481 $parentsourcekey = $this->folderid; 482 } 483 $entryid = mapi_msgstore_entryidfromsourcekey($this->store, $parentsourcekey, $sourcekey); 484 485 if(!$entryid) { 486 ZLog::Write(LOGLEVEL_INFO, sprintf("ReplyBackImExporter->getMessage(): Couldn't retrieve message from MAPIProvider, sourcekey: '%s', parentsourcekey: '%s'", bin2hex($sourcekey), bin2hex($parentsourcekey), bin2hex($entryid))); 487 return false; 488 } 489 490 $mapimessage = mapi_msgstore_openentry($this->store, $entryid); 491 try { 492 ZLog::Write(LOGLEVEL_DEBUG, sprintf("ReplyBackImExporter->getMessage(): Getting message from MAPIProvider, sourcekey: '%s', parentsourcekey: '%s', entryid: '%s'", bin2hex($sourcekey), bin2hex($parentsourcekey), bin2hex($entryid))); 493 $message = $this->mapiprovider->GetMessage($mapimessage, $this->contentparameters); 494 495 // strip or do not send private messages from shared folders to the device 496 if (MAPIUtils::IsMessageSharedAndPrivate($this->folderid, $mapimessage)) { 497 if ($message->SupportsPrivateStripping()) { 498 ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->getMessage(): stripping data of private message from a shared folder"); 499 $message->StripData(Streamer::STRIP_PRIVATE_DATA); 500 } 501 else { 502 ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->getMessage(): ignoring private message from a shared folder"); 503 return SYNC_E_IGNORE; 504 } 505 } 506 } 507 catch (SyncObjectBrokenException $mbe) { 508 if ($announceErrors) { 509 510 $brokenSO = $mbe->GetSyncObject(); 511 if (!$brokenSO) { 512 ZLog::Write(LOGLEVEL_ERROR, sprintf("ReplyBackImExporter->getMessage(): Catched SyncObjectBrokenException but broken SyncObject available")); 513 } 514 else { 515 if (!isset($brokenSO->id)) { 516 $brokenSO->id = "Unknown ID"; 517 ZLog::Write(LOGLEVEL_ERROR, sprintf("ReplyBackImExporter->getMessage(): Catched SyncObjectBrokenException but no ID of object set")); 518 } 519 ZPush::GetDeviceManager()->AnnounceIgnoredMessage(false, $brokenSO->id, $brokenSO); 520 } 521 return false; 522 } 523 } 524 return $message; 525 } 526 527 /** 528 * Sends an email notification to the user containing the data the user tried to save. 529 * 530 * @param SyncObject $message 531 * @param SyncObject $oldmessage 532 * @return void 533 */ 534 private function sendNotificationEmail($message, $oldmessage) { 535 // get email address and full name of the user that performed the operation (auth user in all cases) 536 $userinfo = ZPush::GetBackend()->GetUserDetails(Request::GetAuthUser()); 537 538 // get the name of the folder 539 $foldername = "unknown"; 540 $folderid = bin2hex($this->folderid); 541 $folders = ZPush::GetAdditionalSyncFolders(); 542 if (isset($folders[$folderid]) && isset($folders[$folderid]->displayname)) { 543 $foldername = $folders[$folderid]->displayname; 544 } 545 546 // get the foldername from MAPI when impersonating - ZP-1369 547 if ($foldername == "unknown") { 548 $entryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid); 549 $mapifolder = mapi_msgstore_openentry($this->store, $entryid); 550 $folderprops = mapi_getprops($mapifolder, array(PR_DISPLAY_NAME)); 551 if (isset($folderprops[PR_DISPLAY_NAME])) { 552 $foldername = $folderprops[PR_DISPLAY_NAME]; 553 } 554 } 555 556 // get the differences between the two objects 557 $data = substr(get_class($oldmessage), 4) . "\r\n"; 558 // get the suppported fields as we need them to determine the ghosted properties 559 $supportedFields = ZPush::GetDeviceManager()->GetSupportedFields(ZPush::GetDeviceManager()->GetFolderIdForBackendId($folderid)); 560 $dataarray = $oldmessage->EvaluateAndCompare($message, @constant('READ_ONLY_NOTIFY_YOURDATA'), $supportedFields); 561 562 foreach($dataarray as $key => $value) { 563 $value = str_replace("\r", "", $value); 564 $value = str_replace("\n", str_pad("\r\n",25), $value); 565 $data .= str_pad(ucfirst($key).":", 25) . $value ."\r\n"; 566 } 567 568 // build a simple mime message 569 $toEmail = $userinfo['emailaddress']; 570 $mail = "From: Z-Push <no-reply>\r\n"; 571 $mail .= "To: $toEmail\r\n"; 572 $mail .= "Content-Type: text/plain; charset=utf-8\r\n"; 573 $mail .= "Subject: ". @constant('READ_ONLY_NOTIFY_SUBJECT'). "\r\n\r\n"; 574 $mail .= @constant('READ_ONLY_NOTIFY_BODY'). "\r\n"; 575 576 // replace values of template 577 $mail = str_replace("**USERFULLNAME**", $userinfo['fullname'], $mail); 578 $mail = str_replace("**DATE**", strftime(@constant('READ_ONLY_NOTIFY_DATE_FORMAT')), $mail); 579 $mail = str_replace("**TIME**", strftime(@constant('READ_ONLY_NOTIFY_TIME_FORMAT')), $mail); 580 $mail = str_replace("**FOLDERNAME**", $foldername, $mail); 581 $mail = str_replace("**MOBILETYPE**", Request::GetDeviceType(), $mail); 582 $mail = str_replace("**MOBILEDEVICEID**", Request::GetDeviceID(), $mail); 583 $mail = str_replace("**DIFFERENCES**", $data, $mail); 584 585 // user send email to himself 586 $m = new SyncSendMail(); 587 $m->saveinsent = false; 588 $m->replacemime = true; 589 $m->mime = $mail; 590 591 ZPush::GetBackend()->SendMail($m); 592 } 593 594} 595