]> git.donarmstrong.com Git - roundcube.git/blob - program/include/rcube_imap_cache.php
Imported Upstream version 0.7
[roundcube.git] / program / include / rcube_imap_cache.php
1 <?php
2
3 /*
4  +-----------------------------------------------------------------------+
5  | program/include/rcube_imap_cache.php                                  |
6  |                                                                       |
7  | This file is part of the Roundcube Webmail client                     |
8  | Copyright (C) 2005-2011, The Roundcube Dev Team                       |
9  | Licensed under the GNU GPL                                            |
10  |                                                                       |
11  | PURPOSE:                                                              |
12  |   Caching of IMAP folder contents (messages and index)                |
13  |                                                                       |
14  +-----------------------------------------------------------------------+
15  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
16  | Author: Aleksander Machniak <alec@alec.pl>                            |
17  +-----------------------------------------------------------------------+
18
19  $Id: rcube_imap_cache.php 5366 2011-10-26 11:48:27Z thomasb $
20
21 */
22
23
24 /**
25  * Interface class for accessing Roundcube messages cache
26  *
27  * @package    Cache
28  * @author     Thomas Bruederli <roundcube@gmail.com>
29  * @author     Aleksander Machniak <alec@alec.pl>
30  * @version    1.0
31  */
32 class rcube_imap_cache
33 {
34     /**
35      * Instance of rcube_imap
36      *
37      * @var rcube_imap
38      */
39     private $imap;
40
41     /**
42      * Instance of rcube_mdb2
43      *
44      * @var rcube_mdb2
45      */
46     private $db;
47
48     /**
49      * User ID
50      *
51      * @var int
52      */
53     private $userid;
54
55     /**
56      * Internal (in-memory) cache
57      *
58      * @var array
59      */
60     private $icache = array();
61
62     private $skip_deleted = false;
63
64     /**
65      * List of known flags. Thanks to this we can handle flag changes
66      * with good performance. Bad thing is we need to know used flags.
67      */
68     public $flags = array(
69         1       => 'SEEN',          // RFC3501
70         2       => 'DELETED',       // RFC3501
71         4       => 'ANSWERED',      // RFC3501
72         8       => 'FLAGGED',       // RFC3501
73         16      => 'DRAFT',         // RFC3501
74         32      => 'MDNSENT',       // RFC3503
75         64      => 'FORWARDED',     // RFC5550
76         128     => 'SUBMITPENDING', // RFC5550
77         256     => 'SUBMITTED',     // RFC5550
78         512     => 'JUNK',
79         1024    => 'NONJUNK',
80         2048    => 'LABEL1',
81         4096    => 'LABEL2',
82         8192    => 'LABEL3',
83         16384   => 'LABEL4',
84         32768   => 'LABEL5',
85     );
86
87
88     /**
89      * Object constructor.
90      */
91     function __construct($db, $imap, $userid, $skip_deleted)
92     {
93         $this->db           = $db;
94         $this->imap         = $imap;
95         $this->userid       = (int)$userid;
96         $this->skip_deleted = $skip_deleted;
97     }
98
99
100     /**
101      * Cleanup actions (on shutdown).
102      */
103     public function close()
104     {
105         $this->save_icache();
106         $this->icache = null;
107     }
108
109
110     /**
111      * Return (sorted) messages index.
112      * If index doesn't exist or is invalid, will be updated.
113      *
114      * @param string  $mailbox     Folder name
115      * @param string  $sort_field  Sorting column
116      * @param string  $sort_order  Sorting order (ASC|DESC)
117      * @param bool    $exiting     Skip index initialization if it doesn't exist in DB
118      *
119      * @return array Messages index
120      */
121     function get_index($mailbox, $sort_field = null, $sort_order = null, $existing = false)
122     {
123         if (empty($this->icache[$mailbox]))
124             $this->icache[$mailbox] = array();
125
126         $sort_order = strtoupper($sort_order) == 'ASC' ? 'ASC' : 'DESC';
127
128         // Seek in internal cache
129         if (array_key_exists('index', $this->icache[$mailbox])) {
130             // The index was fetched from database already, but not validated yet
131             if (!array_key_exists('result', $this->icache[$mailbox]['index'])) {
132                 $index = $this->icache[$mailbox]['index'];
133             }
134             // We've got a valid index
135             else if ($sort_field == 'ANY' || $this->icache[$mailbox]['index']['sort_field'] == $sort_field
136             ) {
137                 if ($this->icache[$mailbox]['index']['sort_order'] == $sort_order)
138                     return $this->icache[$mailbox]['index']['result'];
139                 else
140                     return array_reverse($this->icache[$mailbox]['index']['result'], true);
141             }
142         }
143
144         // Get index from DB (if DB wasn't already queried)
145         if (empty($index) && empty($this->icache[$mailbox]['index_queried'])) {
146             $index = $this->get_index_row($mailbox);
147
148             // set the flag that DB was already queried for index
149             // this way we'll be able to skip one SELECT, when
150             // get_index() is called more than once
151             $this->icache[$mailbox]['index_queried'] = true;
152         }
153
154         $data = null;
155
156         // @TODO: Think about skipping validation checks.
157         // If we could check only every 10 minutes, we would be able to skip
158         // expensive checks, mailbox selection or even IMAP connection, this would require
159         // additional logic to force cache invalidation in some cases
160         // and many rcube_imap changes to connect when needed
161
162         // Entry exists, check cache status
163         if (!empty($index)) {
164             $exists = true;
165
166             if ($sort_field == 'ANY') {
167                 $sort_field = $index['sort_field'];
168             }
169
170             if ($sort_field != $index['sort_field']) {
171                 $is_valid = false;
172             }
173             else {
174                 $is_valid = $this->validate($mailbox, $index, $exists);
175             }
176
177             if ($is_valid) {
178                 // build index, assign sequence IDs to unique IDs
179                 $data = array_combine($index['seq'], $index['uid']);
180                 // revert the order if needed
181                 if ($index['sort_order'] != $sort_order)
182                     $data = array_reverse($data, true);
183             }
184         }
185         else {
186             if ($existing) {
187                 return null;
188             }
189             else if ($sort_field == 'ANY') {
190                 $sort_field = '';
191             }
192
193             // Got it in internal cache, so the row already exist
194             $exists = array_key_exists('index', $this->icache[$mailbox]);
195         }
196
197         // Index not found, not valid or sort field changed, get index from IMAP server
198         if ($data === null) {
199             // Get mailbox data (UIDVALIDITY, counters, etc.) for status check
200             $mbox_data = $this->imap->mailbox_data($mailbox);
201             $data      = $this->get_index_data($mailbox, $sort_field, $sort_order, $mbox_data);
202
203             // insert/update
204             $this->add_index_row($mailbox, $sort_field, $sort_order, $data, $mbox_data,
205                 $exists, $index['modseq']);
206         }
207
208         $this->icache[$mailbox]['index'] = array(
209             'result'     => $data,
210             'sort_field' => $sort_field,
211             'sort_order' => $sort_order,
212             'modseq'     => !empty($index['modseq']) ? $index['modseq'] : $mbox_data['HIGHESTMODSEQ']
213         );
214
215         return $data;
216     }
217
218
219     /**
220      * Return messages thread.
221      * If threaded index doesn't exist or is invalid, will be updated.
222      *
223      * @param string  $mailbox     Folder name
224      * @param string  $sort_field  Sorting column
225      * @param string  $sort_order  Sorting order (ASC|DESC)
226      *
227      * @return array Messages threaded index
228      */
229     function get_thread($mailbox)
230     {
231         if (empty($this->icache[$mailbox]))
232             $this->icache[$mailbox] = array();
233
234         // Seek in internal cache
235         if (array_key_exists('thread', $this->icache[$mailbox])) {
236             return array(
237                 $this->icache[$mailbox]['thread']['tree'],
238                 $this->icache[$mailbox]['thread']['depth'],
239                 $this->icache[$mailbox]['thread']['children'],
240             );
241         }
242
243         // Get thread from DB (if DB wasn't already queried)
244         if (empty($this->icache[$mailbox]['thread_queried'])) {
245             $index = $this->get_thread_row($mailbox);
246
247             // set the flag that DB was already queried for thread
248             // this way we'll be able to skip one SELECT, when
249             // get_thread() is called more than once or after clear()
250             $this->icache[$mailbox]['thread_queried'] = true;
251         }
252
253         $data = null;
254
255         // Entry exist, check cache status
256         if (!empty($index)) {
257             $exists   = true;
258             $is_valid = $this->validate($mailbox, $index, $exists);
259
260             if (!$is_valid) {
261                 $index = null;
262             }
263         }
264
265         // Index not found or not valid, get index from IMAP server
266         if ($index === null) {
267             // Get mailbox data (UIDVALIDITY, counters, etc.) for status check
268             $mbox_data = $this->imap->mailbox_data($mailbox);
269
270             if ($mbox_data['EXISTS']) {
271                 // get all threads (default sort order)
272                 list ($thread_tree, $msg_depth, $has_children) = $this->imap->fetch_threads($mailbox, true);
273             }
274
275             $index = array(
276                 'tree'     => !empty($thread_tree) ? $thread_tree : array(),
277                 'depth'    => !empty($msg_depth) ? $msg_depth : array(),
278                 'children' => !empty($has_children) ? $has_children : array(),
279             );
280
281             // insert/update
282             $this->add_thread_row($mailbox, $index, $mbox_data, $exists);
283         }
284
285         $this->icache[$mailbox]['thread'] = $index;
286
287         return array($index['tree'], $index['depth'], $index['children']);
288     }
289
290
291     /**
292      * Returns list of messages (headers). See rcube_imap::fetch_headers().
293      *
294      * @param string $mailbox  Folder name
295      * @param array  $msgs     Message sequence numbers
296      * @param bool   $is_uid   True if $msgs contains message UIDs
297      *
298      * @return array The list of messages (rcube_mail_header) indexed by UID
299      */
300     function get_messages($mailbox, $msgs = array(), $is_uid = true)
301     {
302         if (empty($msgs)) {
303             return array();
304         }
305
306         // @TODO: it would be nice if we could work with UIDs only
307         // then index would be not needed. For now we need it to
308         // map id to uid here and to update message id for cached message
309
310         // Convert IDs to UIDs
311         $index = $this->get_index($mailbox, 'ANY');
312         if (!$is_uid) {
313             foreach ($msgs as $idx => $msgid)
314                 if ($uid = $index[$msgid])
315                     $msgs[$idx] = $uid;
316         }
317
318         // Fetch messages from cache
319         $sql_result = $this->db->query(
320             "SELECT uid, data, flags"
321             ." FROM ".get_table_name('cache_messages')
322             ." WHERE user_id = ?"
323                 ." AND mailbox = ?"
324                 ." AND uid IN (".$this->db->array2list($msgs, 'integer').")",
325             $this->userid, $mailbox);
326
327         $msgs   = array_flip($msgs);
328         $result = array();
329
330         while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
331             $uid          = intval($sql_arr['uid']);
332             $result[$uid] = $this->build_message($sql_arr);
333
334             // save memory, we don't need message body here (?)
335             $result[$uid]->body = null;
336
337             // update message ID according to index data
338             if (!empty($index) && ($id = array_search($uid, $index)))
339                 $result[$uid]->id = $id;
340
341             if (!empty($result[$uid])) {
342                 unset($msgs[$uid]);
343             }
344         }
345
346         // Fetch not found messages from IMAP server
347         if (!empty($msgs)) {
348             $messages = $this->imap->fetch_headers($mailbox, array_keys($msgs), true, true);
349
350             // Insert to DB and add to result list
351             if (!empty($messages)) {
352                 foreach ($messages as $msg) {
353                     $this->add_message($mailbox, $msg, !array_key_exists($msg->uid, $result));
354                     $result[$msg->uid] = $msg;
355                 }
356             }
357         }
358
359         return $result;
360     }
361
362
363     /**
364      * Returns message data.
365      *
366      * @param string $mailbox  Folder name
367      * @param int    $uid      Message UID
368      * @param bool   $update   If message doesn't exists in cache it will be fetched
369      *                         from IMAP server
370      * @param bool   $no_cache Enables internal cache usage
371      *
372      * @return rcube_mail_header Message data
373      */
374     function get_message($mailbox, $uid, $update = true, $cache = true)
375     {
376         // Check internal cache
377         if (($message = $this->icache['message'])
378             && $message['mailbox'] == $mailbox && $message['object']->uid == $uid
379         ) {
380             return $this->icache['message']['object'];
381         }
382
383         $sql_result = $this->db->query(
384             "SELECT flags, data"
385             ." FROM ".get_table_name('cache_messages')
386             ." WHERE user_id = ?"
387                 ." AND mailbox = ?"
388                 ." AND uid = ?",
389                 $this->userid, $mailbox, (int)$uid);
390
391         if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
392             $message = $this->build_message($sql_arr);
393             $found   = true;
394
395             // update message ID according to index data
396             $index = $this->get_index($mailbox, 'ANY');
397             if (!empty($index) && ($id = array_search($uid, $index)))
398                 $message->id = $id;
399         }
400
401         // Get the message from IMAP server
402         if (empty($message) && $update) {
403             $message = $this->imap->get_headers($uid, $mailbox, true);
404             // cache will be updated in close(), see below
405         }
406
407         // Save the message in internal cache, will be written to DB in close()
408         // Common scenario: user opens unseen message
409         // - get message (SELECT)
410         // - set message headers/structure (INSERT or UPDATE)
411         // - set \Seen flag (UPDATE)
412         // This way we can skip one UPDATE
413         if (!empty($message) && $cache) {
414             // Save current message from internal cache
415             $this->save_icache();
416
417             $this->icache['message'] = array(
418                 'object'  => $message,
419                 'mailbox' => $mailbox,
420                 'exists'  => $found,
421                 'md5sum'  => md5(serialize($message)),
422             );
423         }
424
425         return $message;
426     }
427
428
429     /**
430      * Saves the message in cache.
431      *
432      * @param string            $mailbox  Folder name
433      * @param rcube_mail_header $message  Message data
434      * @param bool              $force    Skips message in-cache existance check
435      */
436     function add_message($mailbox, $message, $force = false)
437     {
438         if (!is_object($message) || empty($message->uid))
439             return;
440
441         $msg   = serialize($this->db->encode(clone $message));
442         $flags = 0;
443
444         if (!empty($message->flags)) {
445             foreach ($this->flags as $idx => $flag)
446                 if (!empty($message->flags[$flag]))
447                     $flags += $idx;
448         }
449         unset($msg->flags);
450
451         // update cache record (even if it exists, the update
452         // here will work as select, assume row exist if affected_rows=0)
453         if (!$force) {
454             $res = $this->db->query(
455                 "UPDATE ".get_table_name('cache_messages')
456                 ." SET flags = ?, data = ?, changed = ".$this->db->now()
457                 ." WHERE user_id = ?"
458                     ." AND mailbox = ?"
459                     ." AND uid = ?",
460                 $flags, $msg, $this->userid, $mailbox, (int) $message->uid);
461
462             if ($this->db->affected_rows())
463                 return;
464         }
465
466         // insert new record
467         $this->db->query(
468             "INSERT INTO ".get_table_name('cache_messages')
469             ." (user_id, mailbox, uid, flags, changed, data)"
470             ." VALUES (?, ?, ?, ?, ".$this->db->now().", ?)",
471             $this->userid, $mailbox, (int) $message->uid, $flags, $msg);
472     }
473
474
475     /**
476      * Sets the flag for specified message.
477      *
478      * @param string  $mailbox  Folder name
479      * @param array   $uids     Message UIDs or null to change flag
480      *                          of all messages in a folder
481      * @param string  $flag     The name of the flag
482      * @param bool    $enabled  Flag state
483      */
484     function change_flag($mailbox, $uids, $flag, $enabled = false)
485     {
486         $flag = strtoupper($flag);
487         $idx  = (int) array_search($flag, $this->flags);
488
489         if (!$idx) {
490             return;
491         }
492
493         // Internal cache update
494         if ($uids && count($uids) == 1 && ($uid = current($uids))
495             && ($message = $this->icache['message'])
496             && $message['mailbox'] == $mailbox && $message['object']->uid == $uid
497         ) {
498             $message['object']->flags[$flag] = $enabled;
499             return;
500         }
501
502         $this->db->query(
503             "UPDATE ".get_table_name('cache_messages')
504             ." SET changed = ".$this->db->now()
505             .", flags = flags ".($enabled ? "+ $idx" : "- $idx")
506             ." WHERE user_id = ?"
507                 ." AND mailbox = ?"
508                 .($uids !== null ? " AND uid IN (".$this->db->array2list((array)$uids, 'integer').")" : "")
509                 ." AND (flags & $idx) ".($enabled ? "= 0" : "= $idx"),
510             $this->userid, $mailbox);
511     }
512
513
514     /**
515      * Removes message(s) from cache.
516      *
517      * @param string $mailbox  Folder name
518      * @param array  $uids     Message UIDs, NULL removes all messages
519      */
520     function remove_message($mailbox = null, $uids = null)
521     {
522         if (!strlen($mailbox)) {
523             $this->db->query(
524                 "DELETE FROM ".get_table_name('cache_messages')
525                 ." WHERE user_id = ?",
526                 $this->userid);
527         }
528         else {
529             // Remove the message from internal cache
530             if (!empty($uids) && !is_array($uids) && ($message = $this->icache['message'])
531                 && $message['mailbox'] == $mailbox && $message['object']->uid == $uids
532             ) {
533                 $this->icache['message'] = null;
534             }
535
536             $this->db->query(
537                 "DELETE FROM ".get_table_name('cache_messages')
538                 ." WHERE user_id = ?"
539                     ." AND mailbox = ".$this->db->quote($mailbox)
540                     .($uids !== null ? " AND uid IN (".$this->db->array2list((array)$uids, 'integer').")" : ""),
541                 $this->userid);
542         }
543
544     }
545
546
547     /**
548      * Clears index cache.
549      *
550      * @param string  $mailbox     Folder name
551      * @param bool    $remove      Enable to remove the DB row
552      */
553     function remove_index($mailbox = null, $remove = false)
554     {
555         // The index should be only removed from database when
556         // UIDVALIDITY was detected or the mailbox is empty
557         // otherwise use 'valid' flag to not loose HIGHESTMODSEQ value
558         if ($remove)
559             $this->db->query(
560                 "DELETE FROM ".get_table_name('cache_index')
561                 ." WHERE user_id = ".intval($this->userid)
562                     .(strlen($mailbox) ? " AND mailbox = ".$this->db->quote($mailbox) : "")
563             );
564         else
565             $this->db->query(
566                 "UPDATE ".get_table_name('cache_index')
567                 ." SET valid = 0"
568                 ." WHERE user_id = ".intval($this->userid)
569                     .(strlen($mailbox) ? " AND mailbox = ".$this->db->quote($mailbox) : "")
570             );
571
572         if (strlen($mailbox)) {
573             unset($this->icache[$mailbox]['index']);
574             // Index removed, set flag to skip SELECT query in get_index()
575             $this->icache[$mailbox]['index_queried'] = true;
576         }
577         else
578             $this->icache = array();
579     }
580
581
582     /**
583      * Clears thread cache.
584      *
585      * @param string  $mailbox     Folder name
586      */
587     function remove_thread($mailbox = null)
588     {
589         $this->db->query(
590             "DELETE FROM ".get_table_name('cache_thread')
591             ." WHERE user_id = ".intval($this->userid)
592                 .(strlen($mailbox) ? " AND mailbox = ".$this->db->quote($mailbox) : "")
593         );
594
595         if (strlen($mailbox)) {
596             unset($this->icache[$mailbox]['thread']);
597             // Thread data removed, set flag to skip SELECT query in get_thread()
598             $this->icache[$mailbox]['thread_queried'] = true;
599         }
600         else
601             $this->icache = array();
602     }
603
604
605     /**
606      * Clears the cache.
607      *
608      * @param string $mailbox  Folder name
609      * @param array  $uids     Message UIDs, NULL removes all messages in a folder
610      */
611     function clear($mailbox = null, $uids = null)
612     {
613         $this->remove_index($mailbox, true);
614         $this->remove_thread($mailbox);
615         $this->remove_message($mailbox, $uids);
616     }
617
618
619     /**
620      * @param string $mailbox Folder name
621      * @param int    $id      Message (sequence) ID
622      *
623      * @return int Message UID
624      */
625     function id2uid($mailbox, $id)
626     {
627         if (!empty($this->icache['pending_index_update']))
628             return null;
629
630         // get index if it exists
631         $index = $this->get_index($mailbox, 'ANY', null, true);
632
633         return $index[$id];
634     }
635
636
637     /**
638      * @param string $mailbox Folder name
639      * @param int    $uid     Message UID
640      *
641      * @return int Message (sequence) ID
642      */
643     function uid2id($mailbox, $uid)
644     {
645         if (!empty($this->icache['pending_index_update']))
646             return null;
647
648         // get index if it exists
649         $index = $this->get_index($mailbox, 'ANY', null, true);
650
651         return array_search($uid, (array)$index);
652     }
653
654     /**
655      * Fetches index data from database
656      */
657     private function get_index_row($mailbox)
658     {
659         // Get index from DB
660         $sql_result = $this->db->query(
661             "SELECT data, valid"
662             ." FROM ".get_table_name('cache_index')
663             ." WHERE user_id = ?"
664                 ." AND mailbox = ?",
665             $this->userid, $mailbox);
666
667         if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
668             $data = explode('@', $sql_arr['data']);
669
670             return array(
671                 'valid'      => $sql_arr['valid'],
672                 'seq'        => explode(',', $data[0]),
673                 'uid'        => explode(',', $data[1]),
674                 'sort_field' => $data[2],
675                 'sort_order' => $data[3],
676                 'deleted'    => $data[4],
677                 'validity'   => $data[5],
678                 'uidnext'    => $data[6],
679                 'modseq'     => $data[7],
680             );
681         }
682
683         return null;
684     }
685
686
687     /**
688      * Fetches thread data from database
689      */
690     private function get_thread_row($mailbox)
691     {
692         // Get thread from DB
693         $sql_result = $this->db->query(
694             "SELECT data"
695             ." FROM ".get_table_name('cache_thread')
696             ." WHERE user_id = ?"
697                 ." AND mailbox = ?",
698             $this->userid, $mailbox);
699
700         if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
701             $data = explode('@', $sql_arr['data']);
702
703             // Uncompress data, see add_thread_row()
704   //          $data[0] = str_replace(array('*', '^', '#'), array(';a:0:{}', 'i:', ';a:1:'), $data[0]);
705             $data[0] = unserialize($data[0]);
706
707             // build 'depth' and 'children' arrays
708             $depth = $children = array();
709             $this->build_thread_data($data[0], $depth, $children);
710
711             return array(
712                 'tree'     => $data[0],
713                 'depth'    => $depth,
714                 'children' => $children,
715                 'deleted'  => $data[1],
716                 'validity' => $data[2],
717                 'uidnext'  => $data[3],
718             );
719         }
720
721         return null;
722     }
723
724
725     /**
726      * Saves index data into database
727      */
728     private function add_index_row($mailbox, $sort_field, $sort_order,
729         $data = array(), $mbox_data = array(), $exists = false, $modseq = null)
730     {
731         $data = array(
732             implode(',', array_keys($data)),
733             implode(',', array_values($data)),
734             $sort_field,
735             $sort_order,
736             (int) $this->skip_deleted,
737             (int) $mbox_data['UIDVALIDITY'],
738             (int) $mbox_data['UIDNEXT'],
739             $modseq ? $modseq : $mbox_data['HIGHESTMODSEQ'],
740         );
741         $data = implode('@', $data);
742
743         if ($exists)
744             $sql_result = $this->db->query(
745                 "UPDATE ".get_table_name('cache_index')
746                 ." SET data = ?, valid = 1, changed = ".$this->db->now()
747                 ." WHERE user_id = ?"
748                     ." AND mailbox = ?",
749                 $data, $this->userid, $mailbox);
750         else
751             $sql_result = $this->db->query(
752                 "INSERT INTO ".get_table_name('cache_index')
753                 ." (user_id, mailbox, data, valid, changed)"
754                 ." VALUES (?, ?, ?, 1, ".$this->db->now().")",
755                 $this->userid, $mailbox, $data);
756     }
757
758
759     /**
760      * Saves thread data into database
761      */
762     private function add_thread_row($mailbox, $data = array(), $mbox_data = array(), $exists = false)
763     {
764         $tree = serialize($data['tree']);
765         // This significantly reduces data length
766 //        $tree = str_replace(array(';a:0:{}', 'i:', ';a:1:'), array('*', '^', '#'), $tree);
767
768         $data = array(
769             $tree,
770             (int) $this->skip_deleted,
771             (int) $mbox_data['UIDVALIDITY'],
772             (int) $mbox_data['UIDNEXT'],
773         );
774         $data = implode('@', $data);
775
776         if ($exists)
777             $sql_result = $this->db->query(
778                 "UPDATE ".get_table_name('cache_thread')
779                 ." SET data = ?, changed = ".$this->db->now()
780                 ." WHERE user_id = ?"
781                     ." AND mailbox = ?",
782                 $data, $this->userid, $mailbox);
783         else
784             $sql_result = $this->db->query(
785                 "INSERT INTO ".get_table_name('cache_thread')
786                 ." (user_id, mailbox, data, changed)"
787                 ." VALUES (?, ?, ?, ".$this->db->now().")",
788                 $this->userid, $mailbox, $data);
789     }
790
791
792     /**
793      * Checks index/thread validity
794      */
795     private function validate($mailbox, $index, &$exists = true)
796     {
797         $is_thread = isset($index['tree']);
798
799         // Get mailbox data (UIDVALIDITY, counters, etc.) for status check
800         $mbox_data = $this->imap->mailbox_data($mailbox);
801
802         // @TODO: Think about skipping validation checks.
803         // If we could check only every 10 minutes, we would be able to skip
804         // expensive checks, mailbox selection or even IMAP connection, this would require
805         // additional logic to force cache invalidation in some cases
806         // and many rcube_imap changes to connect when needed
807
808         // Check UIDVALIDITY
809         if ($index['validity'] != $mbox_data['UIDVALIDITY']) {
810             $this->clear($mailbox);
811             $exists = false;
812             return false;
813         }
814
815         // Folder is empty but cache isn't
816         if (empty($mbox_data['EXISTS'])) {
817             if (!empty($index['seq']) || !empty($index['tree'])) {
818                 $this->clear($mailbox);
819                 $exists = false;
820                 return false;
821             }
822         }
823         // Folder is not empty but cache is
824         else if (empty($index['seq']) && empty($index['tree'])) {
825             unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']);
826             return false;
827         }
828
829         // Validation flag
830         if (!$is_thread && empty($index['valid'])) {
831             unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']);
832             return false;
833         }
834
835         // Index was created with different skip_deleted setting
836         if ($this->skip_deleted != $index['deleted']) {
837             return false;
838         }
839
840         // Check HIGHESTMODSEQ
841         if (!empty($index['modseq']) && !empty($mbox_data['HIGHESTMODSEQ'])
842             && $index['modseq'] == $mbox_data['HIGHESTMODSEQ']
843         ) {
844             return true;
845         }
846
847         // Check UIDNEXT
848         if ($index['uidnext'] != $mbox_data['UIDNEXT']) {
849             unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']);
850             return false;
851         }
852
853         // @TODO: find better validity check for threaded index
854         if ($is_thread) {
855             // check messages number...
856             if (!$this->skip_deleted && $mbox_data['EXISTS'] != @max(array_keys($index['depth']))) {
857                 return false;
858             }
859             return true;
860         }
861
862         // The rest of checks, more expensive
863         if (!empty($this->skip_deleted)) {
864             // compare counts if available
865             if ($mbox_data['COUNT_UNDELETED'] != null
866                 && $mbox_data['COUNT_UNDELETED'] != count($index['uid'])) {
867                 return false;
868             }
869             // compare UID sets
870             if ($mbox_data['ALL_UNDELETED'] != null) {
871                 $uids_new = rcube_imap_generic::uncompressMessageSet($mbox_data['ALL_UNDELETED']);
872                 $uids_old = $index['uid'];
873
874                 if (count($uids_new) != count($uids_old)) {
875                     return false;
876                 }
877
878                 sort($uids_new, SORT_NUMERIC);
879                 sort($uids_old, SORT_NUMERIC);
880
881                 if ($uids_old != $uids_new)
882                     return false;
883             }
884             else {
885                 // get all undeleted messages excluding cached UIDs
886                 $ids = $this->imap->search_once($mailbox, 'ALL UNDELETED NOT UID '.
887                     rcube_imap_generic::compressMessageSet($index['uid']));
888
889                 if (!empty($ids)) {
890                     return false;
891                 }
892             }
893         }
894         else {
895             // check messages number...
896             if ($mbox_data['EXISTS'] != max($index['seq'])) {
897                 return false;
898             }
899             // ... and max UID
900             if (max($index['uid']) != $this->imap->id2uid($mbox_data['EXISTS'], $mailbox, true)) {
901                 return false;
902             }
903         }
904
905         return true;
906     }
907
908
909     /**
910      * Synchronizes the mailbox.
911      *
912      * @param string $mailbox Folder name
913      */
914     function synchronize($mailbox)
915     {
916         // RFC4549: Synchronization Operations for Disconnected IMAP4 Clients
917         // RFC4551: IMAP Extension for Conditional STORE Operation
918         //          or Quick Flag Changes Resynchronization
919         // RFC5162: IMAP Extensions for Quick Mailbox Resynchronization
920
921         // @TODO: synchronize with other methods?
922         $qresync   = $this->imap->get_capability('QRESYNC');
923         $condstore = $qresync ? true : $this->imap->get_capability('CONDSTORE');
924
925         if (!$qresync && !$condstore) {
926             return;
927         }
928
929         // Get stored index
930         $index = $this->get_index_row($mailbox);
931
932         // database is empty
933         if (empty($index)) {
934             // set the flag that DB was already queried for index
935             // this way we'll be able to skip one SELECT in get_index()
936             $this->icache[$mailbox]['index_queried'] = true;
937             return;
938         }
939
940         $this->icache[$mailbox]['index'] = $index;
941
942         // no last HIGHESTMODSEQ value
943         if (empty($index['modseq'])) {
944             return;
945         }
946
947         // NOTE: make sure the mailbox isn't selected, before
948         // enabling QRESYNC and invoking SELECT
949         if ($this->imap->conn->selected !== null) {
950             $this->imap->conn->close();
951         }
952
953         // Enable QRESYNC
954         $res = $this->imap->conn->enable($qresync ? 'QRESYNC' : 'CONDSTORE');
955         if (!is_array($res)) {
956             return;
957         }
958
959         // Get mailbox data (UIDVALIDITY, HIGHESTMODSEQ, counters, etc.)
960         $mbox_data = $this->imap->mailbox_data($mailbox);
961
962         if (empty($mbox_data)) {
963              return;
964         }
965
966         // Check UIDVALIDITY
967         if ($index['validity'] != $mbox_data['UIDVALIDITY']) {
968             $this->clear($mailbox);
969             return;
970         }
971
972         // QRESYNC not supported on specified mailbox
973         if (!empty($mbox_data['NOMODSEQ']) || empty($mbox_data['HIGHESTMODSEQ'])) {
974             return;
975         }
976
977         // Nothing new
978         if ($mbox_data['HIGHESTMODSEQ'] == $index['modseq']) {
979             return;
980         }
981
982         // Get known uids
983         $uids = array();
984         $sql_result = $this->db->query(
985             "SELECT uid"
986             ." FROM ".get_table_name('cache_messages')
987             ." WHERE user_id = ?"
988                 ." AND mailbox = ?",
989             $this->userid, $mailbox);
990
991         while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
992           $uids[] = $sql_arr['uid'];
993         }
994
995         // No messages in database, nothing to sync
996         if (empty($uids)) {
997             return;
998         }
999
1000         // Get modified flags and vanished messages
1001         // UID FETCH 1:* (FLAGS) (CHANGEDSINCE 0123456789 VANISHED)
1002         $result = $this->imap->conn->fetch($mailbox,
1003             !empty($uids) ? $uids : '1:*', true, array('FLAGS'),
1004             $index['modseq'], $qresync);
1005
1006         $invalidated = false;
1007
1008         if (!empty($result)) {
1009             foreach ($result as $id => $msg) {
1010                 $uid = $msg->uid;
1011                 // Remove deleted message
1012                 if ($this->skip_deleted && !empty($msg->flags['DELETED'])) {
1013                     $this->remove_message($mailbox, $uid);
1014
1015                     if (!$invalidated) {
1016                         $invalidated = true;
1017                         // Invalidate thread indexes (?)
1018                         $this->remove_thread($mailbox);
1019                         // Invalidate index
1020                         $index['valid'] = false;
1021                     }
1022                     continue;
1023                 }
1024
1025                 $flags = 0;
1026                 if (!empty($msg->flags)) {
1027                     foreach ($this->flags as $idx => $flag)
1028                         if (!empty($msg->flags[$flag]))
1029                             $flags += $idx;
1030                 }
1031
1032                 $this->db->query(
1033                     "UPDATE ".get_table_name('cache_messages')
1034                     ." SET flags = ?, changed = ".$this->db->now()
1035                     ." WHERE user_id = ?"
1036                         ." AND mailbox = ?"
1037                         ." AND uid = ?"
1038                         ." AND flags <> ?",
1039                     $flags, $this->userid, $mailbox, $uid, $flags);
1040             }
1041         }
1042
1043         // Get VANISHED
1044         if ($qresync) {
1045             $mbox_data = $this->imap->mailbox_data($mailbox);
1046
1047             // Removed messages
1048             if (!empty($mbox_data['VANISHED'])) {
1049                 $uids = rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']);
1050                 if (!empty($uids)) {
1051                     // remove messages from database
1052                     $this->remove_message($mailbox, $uids);
1053
1054                     // Invalidate thread indexes (?)
1055                     $this->remove_thread($mailbox);
1056                     // Invalidate index
1057                     $index['valid'] = false;
1058                 }
1059             }
1060         }
1061
1062         $sort_field = $index['sort_field'];
1063         $sort_order = $index['sort_order'];
1064         $exists     = true;
1065
1066         // Validate index
1067         if (!$this->validate($mailbox, $index, $exists)) {
1068             // Update index
1069             $data = $this->get_index_data($mailbox, $sort_field, $sort_order, $mbox_data);
1070         }
1071         else {
1072             $data = array_combine($index['seq'], $index['uid']);
1073         }
1074
1075         // update index and/or HIGHESTMODSEQ value
1076         $this->add_index_row($mailbox, $sort_field, $sort_order, $data, $mbox_data, $exists);
1077
1078         // update internal cache for get_index()
1079         $this->icache[$mailbox]['index']['result'] = $data;
1080     }
1081
1082
1083     /**
1084      * Converts cache row into message object.
1085      *
1086      * @param array $sql_arr Message row data
1087      *
1088      * @return rcube_mail_header Message object
1089      */
1090     private function build_message($sql_arr)
1091     {
1092         $message = $this->db->decode(unserialize($sql_arr['data']));
1093
1094         if ($message) {
1095             $message->flags = array();
1096             foreach ($this->flags as $idx => $flag)
1097                 if (($sql_arr['flags'] & $idx) == $idx)
1098                     $message->flags[$flag] = true;
1099         }
1100
1101         return $message;
1102     }
1103
1104
1105     /**
1106      * Creates 'depth' and 'children' arrays from stored thread 'tree' data.
1107      */
1108     private function build_thread_data($data, &$depth, &$children, $level = 0)
1109     {
1110         foreach ((array)$data as $key => $val) {
1111             $children[$key] = !empty($val);
1112             $depth[$key] = $level;
1113             if (!empty($val))
1114                 $this->build_thread_data($val, $depth, $children, $level + 1);
1115         }
1116     }
1117
1118
1119     /**
1120      * Saves message stored in internal cache
1121      */
1122     private function save_icache()
1123     {
1124         // Save current message from internal cache
1125         if ($message = $this->icache['message']) {
1126             // clean up some object's data
1127             $object = $this->message_object_prepare($message['object']);
1128
1129             // calculate current md5 sum
1130             $md5sum = md5(serialize($object));
1131
1132             if ($message['md5sum'] != $md5sum) {
1133                 $this->add_message($message['mailbox'], $object, !$message['exists']);
1134             }
1135
1136             $this->icache['message']['md5sum'] = $md5sum;
1137         }
1138     }
1139
1140
1141     /**
1142      * Prepares message object to be stored in database.
1143      */
1144     private function message_object_prepare($msg)
1145     {
1146         // Remove body too big (>25kB)
1147         if ($msg->body && strlen($msg->body) > 25 * 1024) {
1148             unset($msg->body);
1149         }
1150
1151         // Fix mimetype which might be broken by some code when message is displayed
1152         // Another solution would be to use object's copy in rcube_message class
1153         // to prevent related issues, however I'm not sure which is better
1154         if ($msg->mimetype) {
1155             list($msg->ctype_primary, $msg->ctype_secondary) = explode('/', $msg->mimetype);
1156         }
1157
1158         if (is_array($msg->structure->parts)) {
1159             foreach ($msg->structure->parts as $idx => $part) {
1160                 $msg->structure->parts[$idx] = $this->message_object_prepare($part);
1161             }
1162         }
1163
1164         return $msg;
1165     }
1166
1167
1168     /**
1169      * Fetches index data from IMAP server
1170      */
1171     private function get_index_data($mailbox, $sort_field, $sort_order, $mbox_data = array())
1172     {
1173         $data = array();
1174
1175         if (empty($mbox_data)) {
1176             $mbox_data = $this->imap->mailbox_data($mailbox);
1177         }
1178
1179         // Prevent infinite loop.
1180         // It happens when rcube_imap::message_index_direct() is called.
1181         // There id2uid() is called which will again call get_index() and so on.
1182         if (!$sort_field && !$this->skip_deleted)
1183             $this->icache['pending_index_update'] = true;
1184
1185         if ($mbox_data['EXISTS']) {
1186             // fetch sorted sequence numbers
1187             $data_seq = $this->imap->message_index_direct($mailbox, $sort_field, $sort_order);
1188             // fetch UIDs
1189             if (!empty($data_seq)) {
1190                 // Seek in internal cache
1191                 if (array_key_exists('index', (array)$this->icache[$mailbox])
1192                     && array_key_exists('result', (array)$this->icache[$mailbox]['index'])
1193                 )
1194                     $data_uid = $this->icache[$mailbox]['index']['result'];
1195                 else
1196                     $data_uid = $this->imap->conn->fetchUIDs($mailbox, $data_seq);
1197
1198                 // build index
1199                 if (!empty($data_uid)) {
1200                     foreach ($data_seq as $seq)
1201                         if ($uid = $data_uid[$seq])
1202                             $data[$seq] = $uid;
1203                 }
1204             }
1205         }
1206
1207         // Reset internal flags
1208         $this->icache['pending_index_update'] = false;
1209
1210         return $data;
1211     }
1212 }