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