1<?php
2
3declare(strict_types=1);
4
5/**
6 * @author Lukas Reschke <lukas@owncloud.com>
7 * @author Thomas Müller <thomas.mueller@tmit.eu>
8 *
9 * Mail
10 *
11 * This code is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Affero General Public License, version 3,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 * GNU Affero General Public License for more details.
19 *
20 * You should have received a copy of the GNU Affero General Public License, version 3,
21 * along with this program.  If not, see <http://www.gnu.org/licenses/>
22 *
23 */
24
25namespace OCA\Mail\Cache;
26
27use Exception;
28use Horde_Imap_Client_Cache_Backend;
29use Horde_Imap_Client_Exception;
30use InvalidArgumentException;
31
32/**
33 * This class is inspired by Horde_Imap_Client_Cache_Backend_Cache of the Horde Project
34 */
35class Cache extends Horde_Imap_Client_Cache_Backend {
36
37	/** Cache structure version. */
38	public const VERSION = 3;
39
40	/**
41	 * The cache object.
42	 *
43	 * @var \OCP\ICache
44	 */
45	protected $_cache;
46
47	/**
48	 * The working data for the current pageload.  All changes take place to
49	 * this data.
50	 *
51	 * @var array
52	 */
53	protected $_data = [];
54
55	/**
56	 * The list of cache slices loaded.
57	 *
58	 * @var array
59	 */
60	protected $_loaded = [];
61
62	/**
63	 * The mapping of UIDs to slices.
64	 *
65	 * @var array
66	 */
67	protected $_slicemap = [];
68
69	/**
70	 * The list of items to update:
71	 *   - add: (array) List of IDs that were added.
72	 *   - slice: (array) List of slices that were modified.
73	 *   - slicemap: (boolean) Was slicemap info changed?
74	 *
75	 * @var array
76	 */
77	protected $_update = [];
78
79	/**
80	 * Constructor.
81	 *
82	 * @param array $params Configuration parameters:
83	 */
84	public function __construct(array $params = []) {
85		// Default parameters.
86		$params = array_merge([
87			'lifetime' => 604800,
88			'slicesize' => 50
89		], array_filter($params));
90
91		if (!isset($params['cacheob'])) {
92			throw new InvalidArgumentException('Missing cacheob parameter.');
93		}
94
95		foreach (['lifetime', 'slicesize'] as $val) {
96			$params[$val] = intval($params[$val]);
97		}
98
99		parent::__construct($params);
100	}
101
102	/**
103	 * Initialization tasks.
104	 */
105	protected function _initOb() {
106		$this->_cache = $this->_params['cacheob'];
107		register_shutdown_function([$this, 'save']);
108	}
109
110	/**
111	 * Updates the cache.
112	 */
113	public function save(): void {
114		$lifetime = $this->_params['lifetime'];
115
116		foreach ($this->_update as $mbox => $val) {
117			$s = &$this->_slicemap[$mbox];
118
119			if (!empty($val['add'])) {
120				if ($s['c'] <= $this->_params['slicesize']) {
121					$val['slice'][] = $s['i'];
122					$this->_loadSlice($mbox, $s['i']);
123				}
124				$val['slicemap'] = true;
125
126				foreach (array_keys(array_flip($val['add'])) as $uid) {
127					if ($s['c']++ > $this->_params['slicesize']) {
128						$s['c'] = 0;
129						$val['slice'][] = ++$s['i'];
130						$this->_loadSlice($mbox, $s['i']);
131					}
132					$s['s'][$uid] = $s['i'];
133				}
134			}
135
136			if (!empty($val['slice'])) {
137				$d = &$this->_data[$mbox];
138				$val['slicemap'] = true;
139
140				foreach (array_keys(array_flip($val['slice'])) as $slice) {
141					$data = [];
142					foreach (array_keys($s['s'], $slice) as $uid) {
143						$data[$uid] = is_array($d[$uid])
144							? serialize($d[$uid])
145							: $d[$uid];
146					}
147					$this->_cache->set($this->_getCid($mbox, $slice), serialize($data), $lifetime);
148				}
149			}
150
151			if (!empty($val['slicemap'])) {
152				$this->_cache->set($this->_getCid($mbox, 'slicemap'), serialize($s), $lifetime);
153			}
154		}
155
156		$this->_update = [];
157	}
158
159	/** {@inheritDoc} */
160	public function get($mailbox, $uids, $fields, $uidvalid) {
161		$ret = [];
162		$this->_loadUids($mailbox, $uids, $uidvalid);
163
164		if (empty($this->_data[$mailbox])) {
165			return $ret;
166		}
167
168		if (!empty($fields)) {
169			$fields = array_flip($fields);
170		}
171		$ptr = &$this->_data[$mailbox];
172
173		foreach (array_intersect($uids, array_keys($ptr)) as $val) {
174			if (is_string($ptr[$val])) {
175				try {
176					$ptr[$val] = @unserialize($ptr[$val]);
177				} catch (Exception $e) {
178				}
179			}
180
181			$ret[$val] = (empty($fields) || empty($ptr[$val]))
182				? $ptr[$val]
183				: array_intersect_key($ptr[$val], $fields);
184		}
185
186		return $ret;
187	}
188
189	/** {@inheritDoc} */
190	public function getCachedUids($mailbox, $uidvalid) {
191		$this->_loadSliceMap($mailbox, $uidvalid);
192		return array_unique(array_merge(
193			array_keys($this->_slicemap[$mailbox]['s']),
194			(isset($this->_update[$mailbox]) ? $this->_update[$mailbox]['add'] : [])
195		));
196	}
197
198	/**
199	 * {@inheritDoc}
200	 *
201	 * @return void
202	 */
203	public function set($mailbox, $data, $uidvalid) {
204		$update = array_keys($data);
205
206		try {
207			$this->_loadUids($mailbox, $update, $uidvalid);
208		} catch (Horde_Imap_Client_Exception $e) {
209			// Ignore invalidity - just start building the new cache
210		}
211
212		$d = &$this->_data[$mailbox];
213		$s = &$this->_slicemap[$mailbox]['s'];
214		$add = $updated = [];
215
216		foreach ($data as $k => $v) {
217			if (isset($d[$k])) {
218				if (is_string($d[$k])) {
219					try {
220						$d[$k] = @unserialize($d[$k]);
221					} catch (Exception $e) {
222					}
223				}
224				$d[$k] = is_array($d[$k])
225					? array_merge($d[$k], $v)
226					: $v;
227				if (isset($s[$k])) {
228					$updated[$s[$k]] = true;
229				}
230			} else {
231				$d[$k] = $v;
232				$add[] = $k;
233			}
234		}
235
236		$this->_toUpdate($mailbox, 'add', $add);
237		$this->_toUpdate($mailbox, 'slice', array_keys($updated));
238	}
239
240	/** {@inheritDoc} */
241	public function getMetaData($mailbox, $uidvalid, $entries) {
242		$this->_loadSliceMap($mailbox, $uidvalid);
243
244		return empty($entries)
245			? $this->_slicemap[$mailbox]['d']
246			: array_intersect_key($this->_slicemap[$mailbox]['d'], array_flip($entries));
247	}
248
249	/**
250	 * {@inheritDoc}
251	 *
252	 * @return void
253	 */
254	public function setMetaData($mailbox, $data) {
255		$this->_loadSliceMap($mailbox, isset($data['uidvalid']) ? $data['uidvalid'] : null);
256		$this->_slicemap[$mailbox]['d'] = array_merge($this->_slicemap[$mailbox]['d'], $data);
257		$this->_toUpdate($mailbox, 'slicemap', true);
258	}
259
260	/**
261	 * {@inheritDoc}
262	 *
263	 * @return void
264	 */
265	public function deleteMsgs($mailbox, $uids) {
266		$this->_loadSliceMap($mailbox);
267
268		$slicemap = &$this->_slicemap[$mailbox];
269		$deleted = array_intersect_key($slicemap['s'], array_flip($uids));
270
271		if (isset($this->_update[$mailbox])) {
272			$this->_update[$mailbox]['add'] = array_diff(
273				$this->_update[$mailbox]['add'],
274				$uids
275			);
276		}
277
278		if (empty($deleted)) {
279			return;
280		}
281
282		$this->_loadUids($mailbox, array_keys($deleted));
283		$d = &$this->_data[$mailbox];
284
285		foreach (array_keys($deleted) as $id) {
286			unset($d[$id], $slicemap['s'][$id]);
287		}
288
289		foreach (array_unique($deleted) as $slice) {
290			/* Get rid of slice if less than 10% of capacity. */
291			if (($slice !== $slicemap['i']) &&
292				($slice_uids = array_keys($slicemap['s'], $slice)) &&
293				($this->_params['slicesize'] * 0.1) > count($slice_uids)) {
294				$this->_toUpdate($mailbox, 'add', $slice_uids);
295				$this->_cache->remove($this->_getCid($mailbox, $slice));
296				foreach ($slice_uids as $val) {
297					unset($slicemap['s'][$val]);
298				}
299			} else {
300				$this->_toUpdate($mailbox, 'slice', [$slice]);
301			}
302		}
303	}
304
305	/**
306	 * {@inheritDoc}
307	 *
308	 * @return void
309	 */
310	public function deleteMailbox($mailbox) {
311		$this->_loadSliceMap($mailbox);
312		$this->_deleteMailbox($mailbox);
313	}
314
315	/**
316	 * {@inheritDoc}
317	 *
318	 * @return void
319	 */
320	public function clear($lifetime) {
321		$this->_cache->clear();
322		$this->_data = $this->_loaded = $this->_slicemap = $this->_update = [];
323	}
324
325	/**
326	 * Create the unique ID used to store the data in the cache.
327	 *
328	 * @param string $mailbox The mailbox to cache.
329	 * @param string $slice The cache slice.
330	 *
331	 * @return string  The cache ID.
332	 */
333	protected function _getCid($mailbox, $slice) {
334		return implode('|', [
335			'horde_imap_client',
336			$this->_params['username'],
337			$mailbox,
338			$this->_params['hostspec'],
339			$this->_params['port'],
340			$slice,
341			self::VERSION
342		]);
343	}
344
345	/**
346	 * Delete a mailbox from the cache.
347	 *
348	 * @param string $mbox The mailbox to delete.
349	 *
350	 * @return void
351	 */
352	protected function _deleteMailbox($mbox): void {
353		foreach (array_merge(array_keys(array_flip($this->_slicemap[$mbox]['s'])), ['slicemap']) as $slice) {
354			$cid = $this->_getCid($mbox, $slice);
355			$this->_cache->remove($cid);
356			unset($this->_loaded[$cid]);
357		}
358
359		unset(
360			$this->_data[$mbox],
361			$this->_slicemap[$mbox],
362			$this->_update[$mbox]
363		);
364	}
365
366	/**
367	 * Load UIDs by regenerating from the cache.
368	 *
369	 * @param string $mailbox The mailbox to load.
370	 * @param array $uids The UIDs to load.
371	 * @param integer $uidvalid The IMAP uidvalidity value of the mailbox.
372	 *
373	 * @return void
374	 */
375	protected function _loadUids($mailbox, $uids, $uidvalid = null): void {
376		if (!isset($this->_data[$mailbox])) {
377			$this->_data[$mailbox] = [];
378		}
379
380		$this->_loadSliceMap($mailbox, $uidvalid);
381
382		if (!empty($uids)) {
383			foreach (array_unique(array_intersect_key($this->_slicemap[$mailbox]['s'], array_flip($uids))) as $slice) {
384				$this->_loadSlice($mailbox, $slice);
385			}
386		}
387	}
388
389	/**
390	 * Load UIDs from a cache slice.
391	 *
392	 * @param string $mailbox The mailbox to load.
393	 * @param integer $slice The slice to load.
394	 *
395	 * @return void
396	 */
397	protected function _loadSlice($mailbox, $slice) {
398		$cache_id = $this->_getCid($mailbox, $slice);
399
400		if (!empty($this->_loaded[$cache_id])) {
401			return;
402		}
403
404		if (($data = $this->_cache->get($cache_id)) !== false) {
405			try {
406				if (is_string($data)) {
407					$data = @unserialize($data);
408				}
409			} catch (Exception $e) {
410			}
411		}
412
413		if (($data !== false) && is_array($data)) {
414			$this->_data[$mailbox] += $data;
415			$this->_loaded[$cache_id] = true;
416		} else {
417			$ptr = &$this->_slicemap[$mailbox];
418
419			// Slice data is corrupt; remove from slicemap.
420			foreach (array_keys($ptr['s'], $slice) as $val) {
421				unset($ptr['s'][$val]);
422			}
423
424			if ($slice === $ptr['i']) {
425				$ptr['c'] = 0;
426			}
427		}
428	}
429
430	/**
431	 * Load the slicemap for a given mailbox.  The slicemap contains
432	 * the uidvalidity information, the UIDs->slice lookup table, and any
433	 * metadata that needs to be saved for the mailbox.
434	 *
435	 * @param string $mailbox The mailbox.
436	 * @param integer $uidvalid The IMAP uidvalidity value of the mailbox.
437	 *
438	 * @return void
439	 */
440	protected function _loadSliceMap($mailbox, $uidvalid = null) {
441		if (!isset($this->_slicemap[$mailbox]) &&
442			(($data = $this->_cache->get($this->_getCid($mailbox, 'slicemap'))) !== false)) {
443			try {
444				if (is_string($data) &&
445					($slice = @unserialize($data)) &&
446					is_array($slice)) {
447					$this->_slicemap[$mailbox] = $slice;
448				}
449			} catch (Exception $e) {
450			}
451		}
452
453		if (isset($this->_slicemap[$mailbox])) {
454			$ptr = &$this->_slicemap[$mailbox];
455			if (is_null($ptr['d']['uidvalid'])) {
456				$ptr['d']['uidvalid'] = $uidvalid;
457				return;
458			} elseif (!is_null($uidvalid) &&
459				($ptr['d']['uidvalid'] !== $uidvalid)) {
460				$this->_deleteMailbox($mailbox);
461			} else {
462				return;
463			}
464		}
465
466		$this->_slicemap[$mailbox] = [
467			// Tracking count for purposes of determining slices
468			'c' => 0,
469			// Metadata storage
470			// By default includes UIDVALIDITY of mailbox.
471			'd' => ['uidvalid' => $uidvalid],
472			// The ID of the last slice.
473			'i' => 0,
474			// The slice list.
475			's' => []
476		];
477	}
478
479	/**
480	 * Add update entry for a mailbox.
481	 *
482	 * @param string $mailbox The mailbox.
483	 * @param string $type 'add', 'slice', or 'slicemap'.
484	 * @param mixed $data The data to update.
485	 *
486	 * @return void
487	 */
488	protected function _toUpdate($mailbox, $type, $data): void {
489		if (!isset($this->_update[$mailbox])) {
490			$this->_update[$mailbox] = [
491				'add' => [],
492				'slice' => []
493			];
494		}
495
496		$this->_update[$mailbox][$type] = ($type === 'slicemap')
497			? $data
498			: array_merge($this->_update[$mailbox][$type], $data);
499	}
500
501	/* Serializable methods. */
502
503	/**
504	 */
505	public function serialize() {
506		$this->save();
507		return parent::serialize();
508	}
509}
510