1<?php
2/***********************************************
3* File      :   importer.php
4* Project   :   Z-Push
5* Descr     :   This is a generic class that is
6*               used by both the proxy importer
7*               (for outgoing messages) and our
8*               local importer (for incoming
9*               messages). Basically all shared
10*               conversion data for converting
11*               to and from MAPI objects is in here.
12*
13* Created   :   14.02.2011
14*
15* Copyright 2007 - 2016 Zarafa Deutschland GmbH
16*
17* This program is free software: you can redistribute it and/or modify
18* it under the terms of the GNU Affero General Public License, version 3,
19* as published by the Free Software Foundation.
20*
21* This program is distributed in the hope that it will be useful,
22* but WITHOUT ANY WARRANTY; without even the implied warranty of
23* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24* GNU Affero General Public License for more details.
25*
26* You should have received a copy of the GNU Affero General Public License
27* along with this program.  If not, see <http://www.gnu.org/licenses/>.
28*
29* Consult LICENSE file for details
30************************************************/
31
32
33
34/**
35 * This is our local importer. Tt receives data from the PDA, for contents and hierarchy changes.
36 * It must therefore receive the incoming data and convert it into MAPI objects, and then send
37 * them to the ICS importer to do the actual writing of the object.
38 * The creation of folders is fairly trivial, because folders that are created on
39 * the PDA are always e-mail folders.
40 */
41
42class ImportChangesICS implements IImportChanges {
43    private $folderid;
44    private $folderidHex;
45    private $store;
46    private $session;
47    private $flags;
48    private $statestream;
49    private $importer;
50    private $memChanges;
51    private $mapiprovider;
52    private $conflictsLoaded;
53    private $conflictsContentParameters;
54    private $conflictsState;
55    private $cutoffdate;
56    private $contentClass;
57    private $prefix;
58    private $moveSrcState;
59    private $moveDstState;
60
61    /**
62     * Constructor
63     *
64     * @param mapisession       $session
65     * @param mapistore         $store
66     * @param string            $folderid (opt)
67     *
68     * @access public
69     * @throws StatusException
70     */
71    public function __construct($session, $store, $folderid = false) {
72        $this->session = $session;
73        $this->store = $store;
74        $this->folderid = $folderid;
75        $this->folderidHex = bin2hex($folderid);
76        $this->conflictsLoaded = false;
77        $this->cutoffdate = false;
78        $this->contentClass = false;
79        $this->prefix = '';
80
81        if ($folderid) {
82            $entryid = mapi_msgstore_entryidfromsourcekey($store, $folderid);
83            $folderidForBackendId = ZPush::GetDeviceManager()->GetFolderIdForBackendId($this->folderidHex);
84            // Only append backend id if the mapping backendid<->folderid is available.
85            if ($folderidForBackendId != $this->folderidHex) {
86                $this->prefix = $folderidForBackendId . ':';
87            }
88        }
89        else {
90            $storeprops = mapi_getprops($store, array(PR_IPM_SUBTREE_ENTRYID, PR_IPM_PUBLIC_FOLDERS_ENTRYID));
91            if (ZPush::GetBackend()->GetImpersonatedUser() == 'system') {
92                $entryid = $storeprops[PR_IPM_PUBLIC_FOLDERS_ENTRYID];
93            }
94            else {
95                $entryid = $storeprops[PR_IPM_SUBTREE_ENTRYID];
96            }
97        }
98
99        $folder = false;
100        if ($entryid)
101            $folder = mapi_msgstore_openentry($store, $entryid);
102
103        if(!$folder) {
104            $this->importer = false;
105
106            // We throw an general error SYNC_FSSTATUS_CODEUNKNOWN (12) which is also SYNC_STATUS_FOLDERHIERARCHYCHANGED (12)
107            // if this happened while doing content sync, the mobile will try to resync the folderhierarchy
108            throw new StatusException(sprintf("ImportChangesICS('%s','%s'): Error, unable to open folder: 0x%X", $session, bin2hex($folderid), mapi_last_hresult()), SYNC_FSSTATUS_CODEUNKNOWN);
109        }
110
111        $this->mapiprovider = new MAPIProvider($this->session, $this->store);
112
113        if ($folderid) {
114            $this->importer = mapi_openproperty($folder, PR_COLLECTOR, IID_IExchangeImportContentsChanges, 0 , 0);
115        }
116        else {
117            $this->importer = mapi_openproperty($folder, PR_COLLECTOR, IID_IExchangeImportHierarchyChanges, 0 , 0);
118        }
119    }
120
121    /**
122     * Initializes the importer
123     *
124     * @param string        $state
125     * @param int           $flags
126     *
127     * @access public
128     * @return boolean
129     * @throws StatusException
130     */
131    public function Config($state, $flags = 0) {
132        $this->flags = $flags;
133
134        // this should never happen
135        if ($this->importer === false)
136            throw new StatusException("ImportChangesICS->Config(): Error, importer not available", SYNC_FSSTATUS_CODEUNKNOWN, null, LOGLEVEL_ERROR);
137
138        // Put the state information in a stream that can be used by ICS
139        $stream = mapi_stream_create();
140        if(strlen($state) == 0)
141            $state = hex2bin("0000000000000000");
142
143        ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->Config(): initializing importer with state: 0x%s", bin2hex($state)));
144
145        mapi_stream_write($stream, $state);
146        $this->statestream = $stream;
147
148        if ($this->folderid !== false) {
149            // possible conflicting messages will be cached here
150            $this->memChanges = new ChangesMemoryWrapper();
151            $stat = mapi_importcontentschanges_config($this->importer, $stream, $flags);
152        }
153        else
154            $stat = mapi_importhierarchychanges_config($this->importer, $stream, $flags);
155
156        if (!$stat)
157            throw new StatusException(sprintf("ImportChangesICS->Config(): Error, mapi_import_*_changes_config() failed: 0x%X", mapi_last_hresult()), SYNC_FSSTATUS_CODEUNKNOWN, null, LOGLEVEL_WARN);
158        return $stat;
159    }
160
161    /**
162     * Configures additional parameters for content selection
163     *
164     * @param ContentParameters         $contentparameters
165     *
166     * @access public
167     * @return boolean
168     * @throws StatusException
169     */
170    public function ConfigContentParameters($contentparameters) {
171        $filtertype = $contentparameters->GetFilterType();
172        switch($contentparameters->GetContentClass()) {
173            case "Email":
174                $this->cutoffdate = ($filtertype) ? Utils::GetCutOffDate($filtertype) : false;
175                break;
176            case "Calendar":
177                $this->cutoffdate = ($filtertype) ? Utils::GetCutOffDate($filtertype) : false;
178                break;
179            default:
180            case "Contacts":
181            case "Tasks":
182                $this->cutoffdate = false;
183                break;
184        }
185        $this->contentClass = $contentparameters->GetContentClass();
186    }
187
188    /**
189     * Reads state from the Importer
190     *
191     * @access public
192     * @return string
193     * @throws StatusException
194     */
195    public function GetState() {
196        $error = false;
197        if(!isset($this->statestream) || $this->importer === false)
198            $error = true;
199
200        if ($error === false && $this->folderid !== false && function_exists("mapi_importcontentschanges_updatestate"))
201            if(mapi_importcontentschanges_updatestate($this->importer, $this->statestream) != true)
202                $error = true;
203
204        if ($error == true)
205            throw new StatusException(sprintf("ImportChangesICS->GetState(): Error, state not available or unable to update: 0x%X", mapi_last_hresult()), (($this->folderid)?SYNC_STATUS_FOLDERHIERARCHYCHANGED:SYNC_FSSTATUS_CODEUNKNOWN), null, LOGLEVEL_WARN);
206
207        mapi_stream_seek($this->statestream, 0, STREAM_SEEK_SET);
208
209        $state = "";
210        while(true) {
211            $data = mapi_stream_read($this->statestream, 4096);
212            if(strlen($data))
213                $state .= $data;
214            else
215                break;
216        }
217
218        return $state;
219    }
220
221    /**
222     * Sets the states from move operations.
223     * When src and dst state are set, a MOVE operation is being executed.
224     *
225     * @param mixed         $srcState
226     * @param mixed         (opt) $dstState, default: null
227     *
228     * @access public
229     * @return boolean
230     */
231    public function SetMoveStates($srcState, $dstState = null) {
232        $this->moveSrcState = $srcState;
233        $this->moveDstState = $dstState;
234        return true;
235    }
236
237    /**
238     * Gets the states of special move operations.
239     *
240     * @access public
241     * @return array(0 => $srcState, 1 => $dstState)
242     */
243    public function GetMoveStates() {
244        return array($this->moveSrcState, $this->moveDstState);
245    }
246
247    /**
248     * Checks if a message may be modified. This involves checking:
249     * - if there is a synchronization interval and if so, if the message is in it (sync window).
250     *   These checks only apply to Emails and Appointments only, Contacts, Tasks and Notes do not have time restrictions.
251     * - if the message is not marked as private in a shared folder.
252     *
253     * @param string     $messageid        the message id to be checked
254     *
255     * @access private
256     * @return boolean
257     */
258    private function isModificationAllowed($messageid) {
259
260        $sharedUser = ZPush::GetAdditionalSyncFolderStore(bin2hex($this->folderid));
261        // if this is either a user folder or SYSTEM and no restriction is set, we don't need to check
262        if (($sharedUser == false || $sharedUser == 'SYSTEM') && $this->cutoffdate === false && !ZPush::GetBackend()->GetImpersonatedUser()) {
263            return true;
264        }
265
266        // open the existing object
267        $entryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid, hex2bin($messageid));
268        if(!$entryid) {
269            ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->isModificationAllowed('%s'): Error, unable to resolve message id: 0x%X", $messageid, mapi_last_hresult()));
270            return false;
271        }
272
273        $mapimessage = mapi_msgstore_openentry($this->store, $entryid);
274        if(!$mapimessage) {
275            ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->isModificationAllowed('%s'): Error, unable to open entry id: 0x%X", $messageid, mapi_last_hresult()));
276            return false;
277        }
278
279        // check the sync interval
280        if ($this->cutoffdate !== false) {
281            ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->isModificationAllowed('%s'): cut off date is: %s", $messageid, $this->cutoffdate));
282            if (  ($this->contentClass == "Email"    && !MAPIUtils::IsInEmailSyncInterval($this->store, $mapimessage, $this->cutoffdate)) ||
283                  ($this->contentClass == "Calendar" && !MAPIUtils::IsInCalendarSyncInterval($this->store, $mapimessage, $this->cutoffdate)) ) {
284
285                ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->isModificationAllowed('%s'): Message in %s is outside the sync interval. Data not saved.", $messageid, $this->contentClass));
286                return false;
287            }
288        }
289
290        // check if not private
291        if (MAPIUtils::IsMessageSharedAndPrivate($this->folderid, $mapimessage)) {
292            ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->isModificationAllowed('%s'): Message is shared and marked as private. Data not saved.", $messageid));
293            return false;
294        }
295
296        // yes, modification allowed
297        return true;
298    }
299
300    /**----------------------------------------------------------------------------------------------------------
301     * Methods for ContentsExporter
302     */
303
304    /**
305     * Loads objects which are expected to be exported with the state
306     * Before importing/saving the actual message from the mobile, a conflict detection should be done
307     *
308     * @param ContentParameters         $contentparameters         class of objects
309     * @param string                    $state
310     *
311     * @access public
312     * @return boolean
313     * @throws StatusException
314     */
315    public function LoadConflicts($contentparameters, $state) {
316        if (!isset($this->session) || !isset($this->store) || !isset($this->folderid))
317            throw new StatusException("ImportChangesICS->LoadConflicts(): Error, can not load changes for conflict detection. Session, store or folder information not available", SYNC_STATUS_SERVERERROR);
318
319        // save data to load changes later if necessary
320        $this->conflictsLoaded = false;
321        $this->conflictsContentParameters = $contentparameters;
322        $this->conflictsState = $state;
323
324        ZLog::Write(LOGLEVEL_DEBUG, "ImportChangesICS->LoadConflicts(): will be loaded later if necessary");
325        return true;
326    }
327
328    /**
329     * Potential conflicts are only loaded when really necessary,
330     * e.g. on ADD or MODIFY
331     *
332     * @access private
333     * @return
334     */
335    private function lazyLoadConflicts() {
336        if (!isset($this->session) || !isset($this->store) || !isset($this->folderid) ||
337            !isset($this->conflictsContentParameters) || $this->conflictsState === false) {
338            ZLog::Write(LOGLEVEL_WARN, "ImportChangesICS->lazyLoadConflicts(): can not load potential conflicting changes in lazymode for conflict detection. Missing information");
339            return false;
340        }
341
342        if (!$this->conflictsLoaded) {
343            ZLog::Write(LOGLEVEL_DEBUG, "ImportChangesICS->lazyLoadConflicts(): loading..");
344
345            // configure an exporter so we can detect conflicts
346            $exporter = new ExportChangesICS($this->session, $this->store, $this->folderid);
347            $exporter->Config($this->conflictsState);
348            $exporter->ConfigContentParameters($this->conflictsContentParameters);
349            $exporter->InitializeExporter($this->memChanges);
350
351            // monitor how long it takes to export potential conflicts
352            // if this takes "too long" we cancel this operation!
353            $potConflicts = $exporter->GetChangeCount();
354            if ($potConflicts > 100) {
355                ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->lazyLoadConflicts(): conflict detection abandoned as there are too many (%d) changes to be exported.", $potConflicts));
356                $this->conflictsLoaded = true;
357                return;
358            }
359            $started = time();
360            $exported = 0;
361            try {
362                while(is_array($exporter->Synchronize())) {
363                    $exported++;
364
365                    // stop if this takes more than 15 seconds and there are more than 5 changes still to be exported
366                    // within 20 seconds this should be finished or it will not be performed
367                    if ((time() - $started) > 15 && ($potConflicts - $exported) > 5 ) {
368                        ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->lazyLoadConflicts(): conflict detection cancelled as operation is too slow. In %d seconds only %d from %d changes were processed.",(time() - $started), $exported, $potConflicts));
369                        $this->conflictsLoaded = true;
370                        return;
371                    }
372                }
373            }
374            // something really bad happened while exporting changes
375            catch (StatusException $stex) {
376                ZLog::Write(LOGLEVEL_WARN, sprintf("ImportChangesICS->lazyLoadConflicts(): got StatusException code %d while exporting changes. Ignore and mark conflicts as loaded.",$stex->getCode()));
377            }
378            $this->conflictsLoaded = true;
379        }
380    }
381
382    /**
383     * Imports a single message
384     *
385     * @param string        $id
386     * @param SyncObject    $message
387     *
388     * @access public
389     * @return boolean/string - failure / id of message
390     * @throws StatusException
391     */
392    public function ImportMessageChange($id, $message) {
393        $flags = 0;
394        $props = array();
395        $props[PR_PARENT_SOURCE_KEY] = $this->folderid;
396
397        // set the PR_SOURCE_KEY if available or mark it as new message
398        if($id) {
399            list(, $sk) = Utils::SplitMessageId($id);
400            $props[PR_SOURCE_KEY] = hex2bin($sk);
401
402            // check if message is in the synchronization interval and/or shared+private
403            if (!$this->isModificationAllowed($sk))
404                throw new StatusException(sprintf("ImportChangesICS->ImportMessageChange('%s','%s'): Message modification is not allowed. Data not saved.", $id, get_class($message)), SYNC_STATUS_SYNCCANNOTBECOMPLETED);
405
406            // check for conflicts
407            $this->lazyLoadConflicts();
408            if($this->memChanges->IsChanged($id)) {
409                if ($this->flags & SYNC_CONFLICT_OVERWRITE_PIM) {
410                    // in these cases the status SYNC_STATUS_CONFLICTCLIENTSERVEROBJECT should be returned, so the mobile client can inform the end user
411                    throw new StatusException(sprintf("ImportChangesICS->ImportMessageChange('%s','%s'): Conflict detected. Data from PIM will be dropped! Server overwrites PIM. User is informed.", $id, get_class($message)), SYNC_STATUS_CONFLICTCLIENTSERVEROBJECT, null, LOGLEVEL_INFO);
412                    return false;
413                }
414                else
415                    ZLog::Write(LOGLEVEL_INFO, sprintf("ImportChangesICS->ImportMessageChange('%s','%s'): Conflict detected. Data from Server will be dropped! PIM overwrites server.", $id, get_class($message)));
416            }
417            if($this->memChanges->IsDeleted($id)) {
418                ZLog::Write(LOGLEVEL_INFO, sprintf("ImportChangesICS->ImportMessageChange('%s','%s'): Conflict detected. Data from PIM will be dropped! Object was deleted on server.", $id, get_class($message)));
419                return false;
420            }
421
422            // KOE ZP-990: OL updates the deleted category which causes a race condition if more than one KOE is connected to that user
423            if(ZPush::GetDeviceManager()->IsKoe() && KOE_CAPABILITY_RECEIVEFLAGS && $message instanceof SyncMail && !isset($message->flag) && isset($message->categories)) {
424                // check if the categories changed
425                $mapiCategories = $this->mapiprovider->GetMessageCategories($props[PR_PARENT_SOURCE_KEY], $props[PR_SOURCE_KEY]);
426                if( (empty($message->categories) && empty($mapiCategories)) ||
427                    (is_array($mapiCategories) && count(array_diff($mapiCategories, $message->categories)) == 0 && count(array_diff($message->categories, $mapiCategories)) == 0)) {
428                    ZLog::Write(LOGLEVEL_DEBUG, "ImportChangesICS->ImportMessageChange(): KOE update of flag categories. Ignoring incoming update.");
429                    return $id;
430                }
431            }
432
433        }
434        else
435            $flags = SYNC_NEW_MESSAGE;
436
437        if(mapi_importcontentschanges_importmessagechange($this->importer, $props, $flags, $mapimessage)) {
438            $this->mapiprovider->SetMessage($mapimessage, $message);
439            mapi_savechanges($mapimessage);
440
441            if (mapi_last_hresult())
442                throw new StatusException(sprintf("ImportChangesICS->ImportMessageChange('%s','%s'): Error, mapi_savechanges() failed: 0x%X", $id, get_class($message), mapi_last_hresult()), SYNC_STATUS_SYNCCANNOTBECOMPLETED);
443
444            $sourcekeyprops = mapi_getprops($mapimessage, array (PR_SOURCE_KEY));
445
446            return $this->prefix . bin2hex($sourcekeyprops[PR_SOURCE_KEY]);
447        }
448        else
449            throw new StatusException(sprintf("ImportChangesICS->ImportMessageChange('%s','%s'): Error updating object: 0x%X", $id, get_class($message), mapi_last_hresult()), SYNC_STATUS_OBJECTNOTFOUND);
450    }
451
452    /**
453     * Imports a deletion. This may conflict if the local object has been modified.
454     *
455     * @param string        $id
456     * @param boolean       $asSoftDelete   (opt) if true, the deletion is exported as "SoftDelete", else as "Remove" - default: false
457     *
458     * @access public
459     * @return boolean
460     */
461    public function ImportMessageDeletion($id, $asSoftDelete = false) {
462        list(,$sk) = Utils::SplitMessageId($id);
463
464        // check if message is in the synchronization interval and/or shared+private
465        if (!$this->isModificationAllowed($sk))
466            throw new StatusException(sprintf("ImportChangesICS->ImportMessageDeletion('%s'): Message deletion is not allowed. Deletion not executed.", $id), SYNC_STATUS_OBJECTNOTFOUND);
467
468        // check for conflicts
469        $this->lazyLoadConflicts();
470        if($this->memChanges->IsChanged($id)) {
471            ZLog::Write(LOGLEVEL_INFO, sprintf("ImportChangesICS->ImportMessageDeletion('%s'): Conflict detected. Data from Server will be dropped! PIM deleted object.", $id));
472        }
473        elseif($this->memChanges->IsDeleted($id)) {
474            ZLog::Write(LOGLEVEL_INFO, sprintf("ImportChangesICS->ImportMessageDeletion('%s'): Conflict detected. Data is already deleted. Request will be ignored.", $id));
475            return true;
476        }
477
478        // do a 'soft' delete so people can un-delete if necessary
479        if(mapi_importcontentschanges_importmessagedeletion($this->importer, 1, array(hex2bin($sk))))
480            throw new StatusException(sprintf("ImportChangesICS->ImportMessageDeletion('%s'): Error updating object: 0x%X", $sk, mapi_last_hresult()), SYNC_STATUS_OBJECTNOTFOUND);
481
482        return true;
483    }
484
485    /**
486     * Imports a change in 'read' flag
487     * This can never conflict
488     *
489     * @param string        $id
490     * @param int           $flags - read/unread
491     * @param array         $categories
492     *
493     * @access public
494     * @return boolean
495     * @throws StatusException
496     */
497    public function ImportMessageReadFlag($id, $flags, $categories = array()) {
498        list($fsk,$sk) = Utils::SplitMessageId($id);
499
500        // if $fsk is set, we convert it into a backend id.
501        if ($fsk) {
502            $fsk = ZPush::GetDeviceManager()->GetBackendIdForFolderId($fsk);
503        }
504
505        // read flag change for our current folder
506        if ($this->folderidHex == $fsk || empty($fsk)) {
507
508            // check if it is in the synchronization interval and/or shared+private
509            if (!$this->isModificationAllowed($sk))
510                throw new StatusException(sprintf("ImportChangesICS->ImportMessageReadFlag('%s','%d'): Flag update is not allowed. Flags not updated.", $id, $flags), SYNC_STATUS_OBJECTNOTFOUND);
511
512            // check for conflicts
513            /*
514             * Checking for conflicts is correct at this point, but is a very expensive operation.
515             * If the message was deleted, only an error will be shown.
516             *
517            $this->lazyLoadConflicts();
518            if($this->memChanges->IsDeleted($id)) {
519                ZLog::Write(LOGLEVEL_INFO, sprintf("ImportChangesICS->ImportMessageReadFlag('%s'): Conflict detected. Data is already deleted. Request will be ignored.", $id));
520                return true;
521            }
522             */
523
524            $readstate = array ( "sourcekey" => hex2bin($sk), "flags" => $flags);
525
526            if(!mapi_importcontentschanges_importperuserreadstatechange($this->importer, array($readstate) ))
527                throw new StatusException(sprintf("ImportChangesICS->ImportMessageReadFlag('%s','%d'): Error setting read state: 0x%X", $id, $flags, mapi_last_hresult()), SYNC_STATUS_OBJECTNOTFOUND);
528        }
529        // yeah OL sucks - ZP-779
530        else {
531            if (!$fsk) {
532                throw new StatusException(sprintf("ImportChangesICS->ImportMessageReadFlag('%s','%d'): Error setting read state. The message is in another folder but id is unknown as no short folder id is available. Please remove your device states to fully resync your device. Operation ignored.", $id, $flags), SYNC_STATUS_OBJECTNOTFOUND);
533            }
534            $store = ZPush::GetBackend()->GetMAPIStoreForFolderId(ZPush::GetAdditionalSyncFolderStore($fsk), $fsk);
535            $entryid = mapi_msgstore_entryidfromsourcekey($store, hex2bin($fsk), hex2bin($sk));
536            $realMessage = mapi_msgstore_openentry($store, $entryid);
537            $flag = 0;
538            if ($flags == 0)
539                $flag |= CLEAR_READ_FLAG;
540            $p = mapi_message_setreadflag($realMessage, $flag);
541            ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->ImportMessageReadFlag('%s','%d'): setting readflag on message: 0x%X", $id, $flags, mapi_last_hresult()));
542        }
543        return true;
544    }
545
546    /**
547     * Imports a move of a message. This occurs when a user moves an item to another folder
548     *
549     * Normally, we would implement this via the 'offical' importmessagemove() function on the ICS importer,
550     * but the Zarafa/Kopano importer does not support this. Therefore we currently implement it via a standard mapi
551     * call. This causes a mirror 'add/delete' to be sent to the PDA at the next sync.
552     * Manfred, 2010-10-21. For some mobiles import was causing duplicate messages in the destination folder
553     * (Mantis #202). Therefore we will create a new message in the destination folder, copy properties
554     * of the source message to the new one and then delete the source message.
555     *
556     * @param string        $id
557     * @param string        $newfolder      destination folder
558     *
559     * @access public
560     * @return boolean
561     * @throws StatusException
562     */
563    public function ImportMessageMove($id, $newfolder) {
564        list(,$sk) = Utils::SplitMessageId($id);
565        if (strtolower($newfolder) == strtolower(bin2hex($this->folderid)) )
566            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, source and destination are equal", $id, $newfolder), SYNC_MOVEITEMSSTATUS_SAMESOURCEANDDEST);
567
568        // Get the entryid of the message we're moving
569        $entryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid, hex2bin($sk));
570        $srcmessage = false;
571
572        if ($entryid) {
573            //open the source message
574            $srcmessage = mapi_msgstore_openentry($this->store, $entryid);
575        }
576
577        if(!$entryid || !$srcmessage) {
578            $code = SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID;
579            $mapiLastHresult = mapi_last_hresult();
580            // if we move to the trash and the source message is not found, we can also just tell the mobile that we successfully moved to avoid errors (ZP-624)
581            if ($newfolder == ZPush::GetBackend()->GetWasteBasket()) {
582                $code = SYNC_MOVEITEMSSTATUS_SUCCESS;
583            }
584            $errorCase = !$entryid ? "resolve source message id" : "open source message";
585            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to %s: 0x%X", $sk, $newfolder, $errorCase, $mapiLastHresult), $code);
586        }
587
588        // check if it is in the synchronization interval and/or shared+private
589        if (!$this->isModificationAllowed($sk))
590            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Source message move is not allowed. Move not performed.", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
591
592        // get correct mapi store for the destination folder
593        $dststore = ZPush::GetBackend()->GetMAPIStoreForFolderId(ZPush::GetAdditionalSyncFolderStore($newfolder), $newfolder);
594        if ($dststore === false)
595            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to open store of destination folder", $sk, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
596
597        $dstentryid = mapi_msgstore_entryidfromsourcekey($dststore, hex2bin($newfolder));
598        if(!$dstentryid)
599            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to resolve destination folder", $sk, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
600
601        $dstfolder = mapi_msgstore_openentry($dststore, $dstentryid);
602        if(!$dstfolder)
603            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to open destination folder", $sk, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
604
605        $newmessage = mapi_folder_createmessage($dstfolder);
606        if (!$newmessage)
607            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to create message in destination folder: 0x%X", $sk, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
608
609        // Copy message
610        mapi_copyto($srcmessage, array(), array(), $newmessage);
611        if (mapi_last_hresult())
612            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, copy to destination message failed: 0x%X", $sk, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_CANNOTMOVE);
613
614        $srcfolderentryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid);
615        if(!$srcfolderentryid)
616            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to resolve source folder", $sk, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
617
618        $srcfolder = mapi_msgstore_openentry($this->store, $srcfolderentryid);
619        if (!$srcfolder)
620            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, unable to open source folder: 0x%X", $sk, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
621
622        // Save changes
623        mapi_savechanges($newmessage);
624        if (mapi_last_hresult())
625            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, mapi_savechanges() failed: 0x%X", $sk, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_CANNOTMOVE);
626
627        // Delete the old message
628        if (!mapi_folder_deletemessages($srcfolder, array($entryid), DELETE_HARD_DELETE))
629            throw new StatusException(sprintf("ImportChangesICS->ImportMessageMove('%s','%s'): Error, delete of source message failed: 0x%X. Possible duplicates.", $sk, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_SOURCEORDESTLOCKED);
630
631        $sourcekeyprops = mapi_getprops($newmessage, array (PR_SOURCE_KEY));
632        if (isset($sourcekeyprops[PR_SOURCE_KEY]) && $sourcekeyprops[PR_SOURCE_KEY]) {
633            $prefix = "";
634            // prepend the destination short folderid, if it exists
635            $destShortId = ZPush::GetDeviceManager()->GetFolderIdForBackendId($newfolder);
636            if ($destShortId !== $newfolder) {
637                $prefix = $destShortId .":";
638            }
639            return $prefix . bin2hex($sourcekeyprops[PR_SOURCE_KEY]);
640        }
641
642        return false;
643    }
644
645
646    /**----------------------------------------------------------------------------------------------------------
647     * Methods for HierarchyExporter
648     */
649
650    /**
651     * Imports a change on a folder
652     *
653     * @param object        $folder     SyncFolder
654     *
655     * @access public
656     * @return boolean|SyncFolder       false on error or a SyncFolder object with serverid and BackendId set (if available)
657     * @throws StatusException
658     */
659    public function ImportFolderChange($folder) {
660        $id = isset($folder->BackendId)?$folder->BackendId : false;
661        $parent = $folder->parentid;
662        $parent_org = $folder->parentid;
663        $displayname = u2wi($folder->displayname);
664        $type = $folder->type;
665
666        if (Utils::IsSystemFolder($type))
667            throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, system folder can not be created/modified", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname), SYNC_FSSTATUS_SYSTEMFOLDER);
668
669        // create a new folder if $id is not set
670        if (!$id) {
671            // the root folder is "0" - get IPM_SUBTREE
672            if ($parent == "0") {
673                $parentprops = mapi_getprops($this->store, array(PR_IPM_SUBTREE_ENTRYID, PR_IPM_PUBLIC_FOLDERS_ENTRYID));
674                if (ZPush::GetBackend()->GetImpersonatedUser() == 'system' && isset($parentprops[PR_IPM_PUBLIC_FOLDERS_ENTRYID])) {
675                    $parentfentryid = $parentprops[PR_IPM_PUBLIC_FOLDERS_ENTRYID];
676                }
677                elseif (isset($parentprops[PR_IPM_SUBTREE_ENTRYID])) {
678                    $parentfentryid = $parentprops[PR_IPM_SUBTREE_ENTRYID];
679                }
680            }
681            else
682                $parentfentryid = mapi_msgstore_entryidfromsourcekey($this->store, hex2bin($parent));
683
684            if (!$parentfentryid)
685                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open parent folder (no entry id)", Utils::PrintAsString(false), $folder->parentid, $displayname), SYNC_FSSTATUS_PARENTNOTFOUND);
686
687            $parentfolder = mapi_msgstore_openentry($this->store, $parentfentryid);
688            if (!$parentfolder)
689                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open parent folder (open entry)", Utils::PrintAsString(false), $folder->parentid, $displayname), SYNC_FSSTATUS_PARENTNOTFOUND);
690
691            //  mapi_folder_createfolder() fails if a folder with this name already exists -> MAPI_E_COLLISION
692            $newfolder = mapi_folder_createfolder($parentfolder, $displayname, "");
693            if (mapi_last_hresult())
694                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, mapi_folder_createfolder() failed: 0x%X", Utils::PrintAsString(false), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_FOLDEREXISTS);
695
696            mapi_setprops($newfolder, array(PR_CONTAINER_CLASS => MAPIUtils::GetContainerClassFromFolderType($type)));
697
698            $props =  mapi_getprops($newfolder, array(PR_SOURCE_KEY));
699            if (isset($props[PR_SOURCE_KEY])) {
700                $folder->BackendId = bin2hex($props[PR_SOURCE_KEY]);
701                $folderOrigin = DeviceManager::FLD_ORIGIN_USER;
702                if (ZPush::GetBackend()->GetImpersonatedUser()) {
703                    $folderOrigin = DeviceManager::FLD_ORIGIN_IMPERSONATED;
704                }
705                $folder->serverid = ZPush::GetDeviceManager()->GetFolderIdForBackendId($folder->BackendId, true, $folderOrigin, $folder->displayname);
706                ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->ImportFolderChange(): Created folder '%s' with id: '%s' backendid: '%s'", $displayname, $folder->serverid, $folder->BackendId));
707                return $folder;
708            }
709            else
710                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, folder created but PR_SOURCE_KEY not available: 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_SERVERERROR);
711        }
712
713        // open folder for update
714        $entryid = mapi_msgstore_entryidfromsourcekey($this->store, hex2bin($id));
715        if (!$entryid)
716            throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open folder (no entry id): 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_PARENTNOTFOUND);
717
718        // check if this is a MAPI default folder
719        if ($this->mapiprovider->IsMAPIDefaultFolder($entryid))
720            throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, MAPI default folder can not be created/modified", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname), SYNC_FSSTATUS_SYSTEMFOLDER);
721
722        $mfolder = mapi_msgstore_openentry($this->store, $entryid);
723        if (!$mfolder)
724            throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open folder (open entry): 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_PARENTNOTFOUND);
725
726        $props =  mapi_getprops($mfolder, array(PR_SOURCE_KEY, PR_PARENT_SOURCE_KEY, PR_DISPLAY_NAME, PR_CONTAINER_CLASS));
727        if (!isset($props[PR_SOURCE_KEY]) || !isset($props[PR_PARENT_SOURCE_KEY]) || !isset($props[PR_DISPLAY_NAME]) || !isset($props[PR_CONTAINER_CLASS]))
728            throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, folder data not available: 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_SERVERERROR);
729
730        // get the real parent source key from mapi
731        if ($parent == "0") {
732            $parentprops = mapi_getprops($this->store, array(PR_IPM_SUBTREE_ENTRYID, PR_IPM_PUBLIC_FOLDERS_ENTRYID));
733            if (ZPush::GetBackend()->GetImpersonatedUser() == 'system') {
734                $parentfentryid = $parentprops[PR_IPM_PUBLIC_FOLDERS_ENTRYID];
735            }
736            else {
737                $parentfentryid = $parentprops[PR_IPM_SUBTREE_ENTRYID];
738            }
739            $mapifolder = mapi_msgstore_openentry($this->store, $parentfentryid);
740
741            $rootfolderprops = mapi_getprops($mapifolder, array(PR_SOURCE_KEY));
742            $parent = bin2hex($rootfolderprops[PR_SOURCE_KEY]);
743            ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->ImportFolderChange(): resolved AS parent '0' to sourcekey '%s'", $parent));
744        }
745
746        // a changed parent id means that the folder should be moved
747        if (bin2hex($props[PR_PARENT_SOURCE_KEY]) !== $parent) {
748            $sourceparentfentryid = mapi_msgstore_entryidfromsourcekey($this->store, $props[PR_PARENT_SOURCE_KEY]);
749            if(!$sourceparentfentryid)
750                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open parent source folder (no entry id): 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_PARENTNOTFOUND);
751
752            $sourceparentfolder = mapi_msgstore_openentry($this->store, $sourceparentfentryid);
753            if(!$sourceparentfolder)
754                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open parent source folder (open entry): 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_PARENTNOTFOUND);
755
756            $destparentfentryid = mapi_msgstore_entryidfromsourcekey($this->store, hex2bin($parent));
757            if(!$sourceparentfentryid)
758                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open destination folder (no entry id): 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_SERVERERROR);
759
760            $destfolder = mapi_msgstore_openentry($this->store, $destparentfentryid);
761            if(!$destfolder)
762                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to open destination folder (open entry): 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_SERVERERROR);
763
764            // mapi_folder_copyfolder() fails if a folder with this name already exists -> MAPI_E_COLLISION
765            if(! mapi_folder_copyfolder($sourceparentfolder, $entryid, $destfolder, $displayname, FOLDER_MOVE))
766                throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, unable to move folder: 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_FOLDEREXISTS);
767
768            // the parent changed, but we got a backendID as parent and have to return an AS folderid - the parent-backendId must be mapped at this point already
769            if ($folder->parentid != 0) {
770                $folder->parentid = ZPush::GetDeviceManager()->GetFolderIdForBackendId($parent);
771            }
772            ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->ImportFolderChange(): Moved folder '%s' with id: %s/%s from: %s to: %s/%s", $displayname, $folder->serverid, $folder->BackendId, bin2hex($props[PR_PARENT_SOURCE_KEY]), $folder->parentid, $parent_org));
773
774            return $folder;
775        }
776
777        // update the display name
778        $props = array(PR_DISPLAY_NAME => $displayname);
779        mapi_setprops($mfolder, $props);
780        mapi_savechanges($mfolder);
781        if (mapi_last_hresult())
782            throw new StatusException(sprintf("ImportChangesICS->ImportFolderChange('%s','%s','%s'): Error, mapi_savechanges() failed: 0x%X", Utils::PrintAsString($folder->serverid), $folder->parentid, $displayname, mapi_last_hresult()), SYNC_FSSTATUS_SERVERERROR);
783
784        ZLog::Write(LOGLEVEL_DEBUG, "Imported changes for folder: $id");
785        return true;
786    }
787
788    /**
789     * Imports a folder deletion
790     *
791     * @param SyncFolder    $folder         at least "serverid" needs to be set
792     *
793     * @access public
794     * @return int          SYNC_FOLDERHIERARCHY_STATUS
795     * @throws StatusException
796     */
797    public function ImportFolderDeletion($folder) {
798        $id = $folder->BackendId;
799        $parent = isset($folder->parentid) ? $folder->parentid : false;
800        ZLog::Write(LOGLEVEL_DEBUG, sprintf("ImportChangesICS->ImportFolderDeletion('%s','%s'): importing folder deletetion", $id, $parent));
801
802        $folderentryid = mapi_msgstore_entryidfromsourcekey($this->store, hex2bin($id));
803        if(!$folderentryid)
804            throw new StatusException(sprintf("ImportChangesICS->ImportFolderDeletion('%s','%s'): Error, unable to resolve folder", $id, $parent, mapi_last_hresult()), SYNC_FSSTATUS_FOLDERDOESNOTEXIST);
805
806        // get the folder type from the MAPIProvider
807        $type = $this->mapiprovider->GetFolderType($folderentryid);
808
809        if (Utils::IsSystemFolder($type) || $this->mapiprovider->IsMAPIDefaultFolder($folderentryid))
810            throw new StatusException(sprintf("ImportChangesICS->ImportFolderDeletion('%s','%s'): Error deleting system/default folder", $id, $parent), SYNC_FSSTATUS_SYSTEMFOLDER);
811
812        $ret = mapi_importhierarchychanges_importfolderdeletion ($this->importer, 0, array(PR_SOURCE_KEY => hex2bin($id)));
813        if (!$ret)
814            throw new StatusException(sprintf("ImportChangesICS->ImportFolderDeletion('%s','%s'): Error deleting folder: 0x%X", $id, $parent, mapi_last_hresult()), SYNC_FSSTATUS_SERVERERROR);
815
816        return $ret;
817    }
818}
819