RoundCube Webmail
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1263 lines
39 KiB

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