1<?php
2/**
3 * Copyright 2008-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file LICENSE for license information (LGPL). If you
6 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
7 *
8 * @category  Horde
9 * @copyright 2008-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
11 * @package   Imap_Client
12 */
13
14/**
15 * Abstraction of the IMAP4rev1 search criteria (see RFC 3501 [6.4.4]).
16 * Allows translation between abstracted search criteria and a generated IMAP
17 * search criteria string suitable for sending to a remote IMAP server.
18 *
19 * @author    Michael Slusarz <slusarz@horde.org>
20 * @category  Horde
21 * @copyright 2008-2017 Horde LLC
22 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
23 * @package   Imap_Client
24 */
25class Horde_Imap_Client_Search_Query implements Serializable
26{
27    /**
28     * Serialized version.
29     */
30    const VERSION = 3;
31
32    /**
33     * Constants for dateSearch()
34     */
35    const DATE_BEFORE = 'BEFORE';
36    const DATE_ON = 'ON';
37    const DATE_SINCE = 'SINCE';
38
39    /**
40     * Constants for intervalSearch()
41     */
42    const INTERVAL_OLDER = 'OLDER';
43    const INTERVAL_YOUNGER = 'YOUNGER';
44
45    /**
46     * The charset of the search strings.  All text strings must be in
47     * this charset. By default, this is 'US-ASCII' (see RFC 3501 [6.4.4]).
48     *
49     * @var string
50     */
51    protected $_charset = null;
52
53    /**
54     * The list of search params.
55     *
56     * @var array
57     */
58    protected $_search = array();
59
60    /**
61     * String representation: The IMAP search string.
62     */
63    public function __toString()
64    {
65        try {
66            $res = $this->build(null);
67            return $res['query']->escape();
68        } catch (Exception $e) {
69            return '';
70        }
71    }
72
73    /**
74     * Sets the charset of the search text.
75     *
76     * @param string $charset   The charset to use for the search.
77     * @param boolean $convert  Convert existing text values?
78     *
79     * @throws Horde_Imap_Client_Exception_SearchCharset
80     */
81    public function charset($charset, $convert = true)
82    {
83        $oldcharset = $this->_charset;
84        $this->_charset = Horde_String::upper($charset);
85
86        if (!$convert || ($oldcharset == $this->_charset)) {
87            return;
88        }
89
90        foreach (array('and', 'or') as $item) {
91            if (isset($this->_search[$item])) {
92                foreach ($this->_search[$item] as &$val) {
93                    $val->charset($charset, $convert);
94                }
95            }
96        }
97
98        // Unset the reference to avoid corrupting $this->_search below.
99        unset($val);
100
101        foreach (array('header', 'text') as $item) {
102            if (isset($this->_search[$item])) {
103                foreach ($this->_search[$item] as $key => $val) {
104                    $new_val = Horde_String::convertCharset($val['text'], $oldcharset, $this->_charset);
105                    if (Horde_String::convertCharset($new_val, $this->_charset, $oldcharset) != $val['text']) {
106                        throw new Horde_Imap_Client_Exception_SearchCharset($this->_charset);
107                    }
108                    $this->_search[$item][$key]['text'] = $new_val;
109                }
110            }
111        }
112    }
113
114    /**
115     * Builds an IMAP4rev1 compliant search string.
116     *
117     * @todo  Change default of $exts to null.
118     *
119     * @param Horde_Imap_Client_Base $exts  The server object this query will
120     *                                      be run on (@since 2.24.0), a
121     *                                      Horde_Imap_Client_Data_Capability
122     *                                      object (@since 2.24.0), or the
123     *                                      list of extensions present
124     *                                      on the server (@deprecated).
125     *                                      If null, all extensions are
126     *                                      assumed to be available.
127     *
128     * @return array  An array with these elements:
129     *   - charset: (string) The charset of the search string. If null, no
130     *              text strings appear in query.
131     *   - exts: (array) The list of IMAP extensions used to create the
132     *           string.
133     *   - query: (Horde_Imap_Client_Data_Format_List) The IMAP search
134     *            command.
135     *
136     * @throws Horde_Imap_Client_Data_Format_Exception
137     * @throws Horde_Imap_Client_Exception_NoSupportExtension
138     */
139    public function build($exts = array())
140    {
141        /* @todo: BC */
142        if (is_array($exts)) {
143            $tmp = new Horde_Imap_Client_Data_Capability_Imap();
144            foreach ($exts as $key => $val) {
145                $tmp->add($key, is_array($val) ? $val : null);
146            }
147            $exts = $tmp;
148        } elseif (!is_null($exts)) {
149            if ($exts instanceof Horde_Imap_Client_Base) {
150                $exts = $exts->capability;
151            } elseif (!($exts instanceof Horde_Imap_Client_Data_Capability)) {
152                throw new InvalidArgumentException('Incorrect $exts parameter');
153            }
154        }
155
156        $temp = array(
157            'cmds' => new Horde_Imap_Client_Data_Format_List(),
158            'exts' => $exts,
159            'exts_used' => array()
160        );
161        $cmds = &$temp['cmds'];
162        $charset = $charset_cname = null;
163        $default_search = true;
164        $exts_used = &$temp['exts_used'];
165        $ptr = &$this->_search;
166
167        $charset_get = function ($c) use (&$charset, &$charset_cname) {
168            $charset = is_null($c)
169                ? 'US-ASCII'
170                : strval($c);
171            $charset_cname = ($charset === 'US-ASCII')
172                ? 'Horde_Imap_Client_Data_Format_Astring'
173                : 'Horde_Imap_Client_Data_Format_Astring_Nonascii';
174        };
175        $create_return = function ($charset, $exts_used, $cmds) {
176            return array(
177                'charset' => $charset,
178                'exts' => array_keys(array_flip($exts_used)),
179                'query' => $cmds
180            );
181        };
182
183        /* Do IDs check first. If there is an empty ID query (without a NOT
184         * qualifier), the rest of this query is irrelevant since we already
185         * know the search will return no results. */
186        if (isset($ptr['ids'])) {
187            if (!count($ptr['ids']['ids']) && !$ptr['ids']['ids']->special) {
188                if (empty($ptr['ids']['not'])) {
189                    /* This is a match on an empty list of IDs. We do need to
190                     * process any OR queries that may exist, since they are
191                     * independent of this result. */
192                    if (isset($ptr['or'])) {
193                        $this->_buildAndOr(
194                            'OR', $ptr['or'], $charset, $exts_used, $cmds
195                        );
196                    }
197                    return $create_return($charset, $exts_used, $cmds);
198                }
199
200                /* If reached here, this a NOT search of an empty list. We can
201                 * safely discard this from the output. */
202            } else {
203                $this->_addFuzzy(!empty($ptr['ids']['fuzzy']), $temp);
204                if (!empty($ptr['ids']['not'])) {
205                    $cmds->add('NOT');
206                }
207                if (!$ptr['ids']['ids']->sequence) {
208                    $cmds->add('UID');
209                }
210                $cmds->add(strval($ptr['ids']['ids']));
211            }
212        }
213
214        if (isset($ptr['new'])) {
215            $this->_addFuzzy(!empty($ptr['newfuzzy']), $temp);
216            if ($ptr['new']) {
217                $cmds->add('NEW');
218                unset($ptr['flag']['UNSEEN']);
219            } else {
220                $cmds->add('OLD');
221            }
222            unset($ptr['flag']['RECENT']);
223        }
224
225        if (!empty($ptr['flag'])) {
226            foreach ($ptr['flag'] as $key => $val) {
227                $this->_addFuzzy(!empty($val['fuzzy']), $temp);
228
229                $tmp = '';
230                if (empty($val['set'])) {
231                    // This is a 'NOT' search.  All system flags but \Recent
232                    // have 'UN' equivalents.
233                    if ($key == 'RECENT') {
234                        $cmds->add('NOT');
235                    } else {
236                        $tmp = 'UN';
237                    }
238                }
239
240                if ($val['type'] == 'keyword') {
241                    $cmds->add(array(
242                        $tmp . 'KEYWORD',
243                        $key
244                    ));
245                } else {
246                    $cmds->add($tmp . $key);
247                }
248            }
249        }
250
251        if (!empty($ptr['header'])) {
252            /* The list of 'system' headers that have a specific search
253             * query. */
254            $systemheaders = array(
255                'BCC', 'CC', 'FROM', 'SUBJECT', 'TO'
256            );
257
258            foreach ($ptr['header'] as $val) {
259                $this->_addFuzzy(!empty($val['fuzzy']), $temp);
260
261                if (!empty($val['not'])) {
262                    $cmds->add('NOT');
263                }
264
265                if (in_array($val['header'], $systemheaders)) {
266                    $cmds->add($val['header']);
267                } else {
268                    $cmds->add(array(
269                        'HEADER',
270                        new Horde_Imap_Client_Data_Format_Astring($val['header'])
271                    ));
272                }
273
274                $charset_get($this->_charset);
275                $cmds->add(
276                    new $charset_cname(isset($val['text']) ? $val['text'] : '')
277                );
278            }
279        }
280
281        if (!empty($ptr['text'])) {
282            foreach ($ptr['text'] as $val) {
283                $this->_addFuzzy(!empty($val['fuzzy']), $temp);
284
285                if (!empty($val['not'])) {
286                    $cmds->add('NOT');
287                }
288
289                $charset_get($this->_charset);
290                $cmds->add(array(
291                    $val['type'],
292                    new $charset_cname($val['text'])
293                ));
294            }
295        }
296
297        if (!empty($ptr['size'])) {
298            foreach ($ptr['size'] as $key => $val) {
299                $this->_addFuzzy(!empty($val['fuzzy']), $temp);
300                if (!empty($val['not'])) {
301                    $cmds->add('NOT');
302                }
303                $cmds->add(array(
304                    $key,
305                    new Horde_Imap_Client_Data_Format_Number(
306                        empty($val['size']) ? 0 : $val['size']
307                    )
308                ));
309            }
310        }
311
312        if (!empty($ptr['date'])) {
313            foreach ($ptr['date'] as $val) {
314                $this->_addFuzzy(!empty($val['fuzzy']), $temp);
315
316                if (!empty($val['not'])) {
317                    $cmds->add('NOT');
318                }
319
320                if (empty($val['header'])) {
321                    $cmds->add($val['range']);
322                } else {
323                    $cmds->add('SENT' . $val['range']);
324                }
325                $cmds->add($val['date']);
326            }
327        }
328
329        if (!empty($ptr['within'])) {
330            if (is_null($exts) || $exts->query('WITHIN')) {
331                $exts_used[] = 'WITHIN';
332            }
333
334            foreach ($ptr['within'] as $key => $val) {
335                $this->_addFuzzy(!empty($val['fuzzy']), $temp);
336                if (!empty($val['not'])) {
337                    $cmds->add('NOT');
338                }
339
340                if (is_null($exts) || $exts->query('WITHIN')) {
341                    $cmds->add(array(
342                        $key,
343                        new Horde_Imap_Client_Data_Format_Number($val['interval'])
344                    ));
345                } else {
346                    // This workaround is only accurate to within 1 day, due
347                    // to limitations with the IMAP4rev1 search commands.
348                    $cmds->add(array(
349                        ($key == self::INTERVAL_OLDER) ? self::DATE_BEFORE : self::DATE_SINCE,
350                        new Horde_Imap_Client_Data_Format_Date('now -' . $val['interval'] . ' seconds')
351                    ));
352                }
353            }
354        }
355
356        if (!empty($ptr['modseq'])) {
357            if (!is_null($exts) && !$exts->query('CONDSTORE')) {
358                throw new Horde_Imap_Client_Exception_NoSupportExtension('CONDSTORE');
359            }
360
361            $exts_used[] = 'CONDSTORE';
362
363            $this->_addFuzzy(!empty($ptr['modseq']['fuzzy']), $temp);
364
365            if (!empty($ptr['modseq']['not'])) {
366                $cmds->add('NOT');
367            }
368            $cmds->add('MODSEQ');
369            if (isset($ptr['modseq']['name'])) {
370                $cmds->add(array(
371                    new Horde_Imap_Client_Data_Format_String($ptr['modseq']['name']),
372                    $ptr['modseq']['type']
373                ));
374            }
375            $cmds->add(new Horde_Imap_Client_Data_Format_Number($ptr['modseq']['value']));
376        }
377
378        if (isset($ptr['prevsearch'])) {
379            if (!is_null($exts) && !$exts->query('SEARCHRES')) {
380                throw new Horde_Imap_Client_Exception_NoSupportExtension('SEARCHRES');
381            }
382
383            $exts_used[] = 'SEARCHRES';
384
385            $this->_addFuzzy(!empty($ptr['prevsearchfuzzy']), $temp);
386
387            if (!$ptr['prevsearch']) {
388                $cmds->add('NOT');
389            }
390            $cmds->add('$');
391        }
392
393        // Add AND'ed queries
394        if (!empty($ptr['and'])) {
395            $default_search = $this->_buildAndOr(
396                'AND', $ptr['and'], $charset, $exts_used, $cmds
397            );
398        }
399
400        // Add OR'ed queries
401        if (!empty($ptr['or'])) {
402            $default_search = $this->_buildAndOr(
403                'OR', $ptr['or'], $charset, $exts_used, $cmds
404            );
405        }
406
407        // Default search is 'ALL'
408        if ($default_search && !count($cmds)) {
409            $cmds->add('ALL');
410        }
411
412        return $create_return($charset, $exts_used, $cmds);
413    }
414
415    /**
416     * Builds the AND/OR query.
417     *
418     * @param string $type                               'AND' or 'OR'.
419     * @param array $data                                Query data.
420     * @param string &$charset                           Search charset.
421     * @param array &$exts_used                          IMAP extensions used.
422     * @param Horde_Imap_Client_Data_Format_List &$cmds  Command list.
423     *
424     * @return boolean  True if query might return results.
425     */
426    protected function _buildAndOr($type, $data, &$charset, &$exts_used,
427                                   &$cmds)
428    {
429        $results = false;
430
431        foreach ($data as $val) {
432            $ret = $val->build();
433
434            /* Empty sub-query. */
435            if (!count($ret['query'])) {
436                switch ($type) {
437                case 'AND':
438                    /* Any empty sub-query means that the query MUST return
439                     * no results. */
440                    $cmds = new Horde_Imap_Client_Data_Format_List();
441                    $exts_used = array();
442                    return false;
443
444                case 'OR':
445                    /* Skip this query. */
446                    continue 2;
447                }
448            }
449
450            $results = true;
451
452            if (!is_null($ret['charset']) && ($ret['charset'] != 'US-ASCII')) {
453                if (!is_null($charset) &&
454                    ($charset != 'US-ASCII') &&
455                    ($charset != $ret['charset'])) {
456                    throw new InvalidArgumentException(
457                        'AND/OR queries must all have the same charset.'
458                    );
459                }
460                $charset = $ret['charset'];
461            }
462
463            $exts_used = array_merge($exts_used, $ret['exts']);
464
465            switch ($type) {
466            case 'AND':
467                $cmds->add($ret['query'], true);
468                break;
469
470            case 'OR':
471                // First OR'd query
472                if (count($cmds)) {
473                    $new_cmds = new Horde_Imap_Client_Data_Format_List();
474                    $new_cmds->add(array(
475                        'OR',
476                        $ret['query'],
477                        $cmds
478                    ));
479                    $cmds = $new_cmds;
480                } else {
481                    $cmds = $ret['query'];
482                }
483                break;
484            }
485        }
486
487        return $results;
488    }
489
490    /**
491     * Adds fuzzy modifier to search keys.
492     *
493     * @param boolean $add  Add the fuzzy modifier?
494     * @param array $temp   Temporary build data.
495     *
496     * @throws Horde_Imap_Client_Exception_NoSupport_Extension
497     */
498    protected function _addFuzzy($add, &$temp)
499    {
500        if ($add) {
501            if (!$temp['exts']->query('SEARCH', 'FUZZY')) {
502                throw new Horde_Imap_Client_Exception_NoSupportExtension('SEARCH=FUZZY');
503            }
504            $temp['cmds']->add('FUZZY');
505            $temp['exts_used'][] = 'SEARCH=FUZZY';
506        }
507    }
508
509    /**
510     * Search for a flag/keywords.
511     *
512     * @param string $name  The flag or keyword name.
513     * @param boolean $set  If true, search for messages that have the flag
514     *                      set.  If false, search for messages that do not
515     *                      have the flag set.
516     * @param array $opts   Additional options:
517     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
518     *            MUST support RFC 6203.
519     */
520    public function flag($name, $set = true, array $opts = array())
521    {
522        $name = Horde_String::upper(ltrim($name, '\\'));
523        if (!isset($this->_search['flag'])) {
524            $this->_search['flag'] = array();
525        }
526
527        /* The list of defined system flags (see RFC 3501 [2.3.2]). */
528        $systemflags = array(
529            'ANSWERED', 'DELETED', 'DRAFT', 'FLAGGED', 'RECENT', 'SEEN'
530        );
531
532        $this->_search['flag'][$name] = array_filter(array(
533            'fuzzy' => !empty($opts['fuzzy']),
534            'set' => $set,
535            'type' => in_array($name, $systemflags) ? 'flag' : 'keyword'
536        ));
537    }
538
539    /**
540     * Determines if flags are a part of the search.
541     *
542     * @return boolean  True if search query involves flags.
543     */
544    public function flagSearch()
545    {
546        return !empty($this->_search['flag']);
547    }
548
549    /**
550     * Search for either new messages (messages that have the '\Recent' flag
551     * but not the '\Seen' flag) or old messages (messages that do not have
552     * the '\Recent' flag).  If new messages are searched, this will clear
553     * any '\Recent' or '\Unseen' flag searches.  If old messages are searched,
554     * this will clear any '\Recent' flag search.
555     *
556     * @param boolean $newmsgs  If true, searches for new messages.  Else,
557     *                          search for old messages.
558     * @param array $opts       Additional options:
559     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
560     *            MUST support RFC 6203.
561     */
562    public function newMsgs($newmsgs = true, array $opts = array())
563    {
564        $this->_search['new'] = $newmsgs;
565        if (!empty($opts['fuzzy'])) {
566            $this->_search['newfuzzy'] = true;
567        }
568    }
569
570    /**
571     * Search for text in the header of a message.
572     *
573     * @param string $header  The header field.
574     * @param string $text    The search text.
575     * @param boolean $not    If true, do a 'NOT' search of $text.
576     * @param array $opts     Additional options:
577     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
578     *            MUST support RFC 6203.
579     */
580    public function headerText($header, $text, $not = false,
581                                array $opts = array())
582    {
583        if (!isset($this->_search['header'])) {
584            $this->_search['header'] = array();
585        }
586        $this->_search['header'][] = array_filter(array(
587            'fuzzy' => !empty($opts['fuzzy']),
588            'header' => Horde_String::upper($header),
589            'text' => $text,
590            'not' => $not
591        ));
592    }
593
594    /**
595     * Search for text in either the entire message, or just the body.
596     *
597     * @param string $text      The search text.
598     * @param string $bodyonly  If true, only search in the body of the
599     *                          message. If false, also search in the headers.
600     * @param boolean $not      If true, do a 'NOT' search of $text.
601     * @param array $opts       Additional options:
602     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
603     *            MUST support RFC 6203.
604     */
605    public function text($text, $bodyonly = true, $not = false,
606                         array $opts = array())
607    {
608        if (!isset($this->_search['text'])) {
609            $this->_search['text'] = array();
610        }
611
612        $this->_search['text'][] = array_filter(array(
613            'fuzzy' => !empty($opts['fuzzy']),
614            'not' => $not,
615            'text' => $text,
616            'type' => $bodyonly ? 'BODY' : 'TEXT'
617        ));
618    }
619
620    /**
621     * Search for messages smaller/larger than a certain size.
622     *
623     * @todo: Remove $not for 3.0
624     *
625     * @param integer $size    The size (in bytes).
626     * @param boolean $larger  Search for messages larger than $size?
627     * @param boolean $not     If true, do a 'NOT' search of $text.
628     * @param array $opts      Additional options:
629     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
630     *            MUST support RFC 6203.
631     */
632    public function size($size, $larger = false, $not = false,
633                         array $opts = array())
634    {
635        if (!isset($this->_search['size'])) {
636            $this->_search['size'] = array();
637        }
638        $this->_search['size'][$larger ? 'LARGER' : 'SMALLER'] = array_filter(array(
639            'fuzzy' => !empty($opts['fuzzy']),
640            'not' => $not,
641            'size' => (float)$size
642        ));
643    }
644
645    /**
646     * Search for messages within a given UID range. Only one message range
647     * can be specified per query.
648     *
649     * @param Horde_Imap_Client_Ids $ids  The list of UIDs to search.
650     * @param boolean $not                If true, do a 'NOT' search of the
651     *                                    UIDs.
652     * @param array $opts                 Additional options:
653     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
654     *            MUST support RFC 6203.
655     */
656    public function ids(Horde_Imap_Client_Ids $ids, $not = false,
657                        array $opts = array())
658    {
659        $this->_search['ids'] = array_filter(array(
660            'fuzzy' => !empty($opts['fuzzy']),
661            'ids' => $ids,
662            'not' => $not
663        ));
664    }
665
666    /**
667     * Search for messages within a date range.
668     *
669     * @param mixed $date    DateTime or Horde_Date object.
670     * @param string $range  Either:
671     *   - Horde_Imap_Client_Search_Query::DATE_BEFORE
672     *   - Horde_Imap_Client_Search_Query::DATE_ON
673     *   - Horde_Imap_Client_Search_Query::DATE_SINCE
674     * @param boolean $header  If true, search using the date in the message
675     *                         headers. If false, search using the internal
676     *                         IMAP date (usually arrival time).
677     * @param boolean $not     If true, do a 'NOT' search of the range.
678     * @param array $opts      Additional options:
679     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
680     *            MUST support RFC 6203.
681     */
682    public function dateSearch($date, $range, $header = true, $not = false,
683                               array $opts = array())
684    {
685        if (!isset($this->_search['date'])) {
686            $this->_search['date'] = array();
687        }
688
689        // We should really be storing the raw DateTime object as data,
690        // but all versions of the query object have converted at this stage.
691        $ob = new Horde_Imap_Client_Data_Format_Date($date);
692
693        $this->_search['date'][] = array_filter(array(
694            'date' => $ob->escape(),
695            'fuzzy' => !empty($opts['fuzzy']),
696            'header' => $header,
697            'range' => $range,
698            'not' => $not
699        ));
700    }
701
702    /**
703     * Search for messages within a date and time range.
704     *
705     * @param mixed $date    DateTime or Horde_Date object.
706     * @param string $range  Either:
707     *   - Horde_Imap_Client_Search_Query::DATE_BEFORE
708     *   - Horde_Imap_Client_Search_Query::DATE_ON
709     *   - Horde_Imap_Client_Search_Query::DATE_SINCE
710     * @param boolean $header  If true, search using the date in the message
711     *                         headers. If false, search using the internal
712     *                         IMAP date (usually arrival time).
713     * @param boolean $not     If true, do a 'NOT' search of the range.
714     * @param array $opts      Additional options:
715     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
716     *            MUST support RFC 6203.
717     */
718    public function dateTimeSearch($date, $range, $header = true, $not = false,
719                                   array $opts = array())
720    {
721        if (!isset($this->_search['date'])) {
722            $this->_search['date'] = array();
723        }
724
725        // We should really be storing the raw DateTime object as data,
726        // but all versions of the query object have converted at this stage.
727        $ob = new Horde_Imap_Client_Data_Format_DateTime($date);
728
729        $this->_search['date'][] = array_filter(array(
730            'date' => $ob->escape(),
731            'fuzzy' => !empty($opts['fuzzy']),
732            'header' => $header,
733            'range' => $range,
734            'not' => $not
735        ));
736    }
737
738    /**
739     * Search for messages within a given interval. Only one interval of each
740     * type can be specified per search query. If the IMAP server supports
741     * the WITHIN extension (RFC 5032), it will be used.  Otherwise, the
742     * search query will be dynamically created using IMAP4rev1 search
743     * terms.
744     *
745     * @param integer $interval  Seconds from the present.
746     * @param string $range      Either:
747     *   - Horde_Imap_Client_Search_Query::INTERVAL_OLDER
748     *   - Horde_Imap_Client_Search_Query::INTERVAL_YOUNGER
749     * @param boolean $not       If true, do a 'NOT' search.
750     * @param array $opts        Additional options:
751     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
752     *            MUST support RFC 6203.
753     */
754    public function intervalSearch($interval, $range, $not = false,
755                                   array $opts = array())
756    {
757        if (!isset($this->_search['within'])) {
758            $this->_search['within'] = array();
759        }
760        $this->_search['within'][$range] = array(
761            'fuzzy' => !empty($opts['fuzzy']),
762            'interval' => $interval,
763            'not' => $not
764        );
765    }
766
767    /**
768     * AND queries - the contents of this query will be AND'ed (in its
769     * entirety) with the contents of EACH of the queries passed in.  All
770     * AND'd queries must share the same charset as this query.
771     *
772     * @param mixed $queries  A query, or an array of queries, to AND with the
773     *                        current query.
774     */
775    public function andSearch($queries)
776    {
777        if (!isset($this->_search['and'])) {
778            $this->_search['and'] = array();
779        }
780
781        if ($queries instanceof Horde_Imap_Client_Search_Query) {
782            $queries = array($queries);
783        }
784
785        $this->_search['and'] = array_merge($this->_search['and'], $queries);
786    }
787
788    /**
789     * OR a query - the contents of this query will be OR'ed (in its entirety)
790     * with the contents of EACH of the queries passed in.  All OR'd queries
791     * must share the same charset as this query.  All contents of any single
792     * query will be AND'ed together.
793     *
794     * @param mixed $queries  A query, or an array of queries, to OR with the
795     *                        current query.
796     */
797    public function orSearch($queries)
798    {
799        if (!isset($this->_search['or'])) {
800            $this->_search['or'] = array();
801        }
802
803        if ($queries instanceof Horde_Imap_Client_Search_Query) {
804            $queries = array($queries);
805        }
806
807        $this->_search['or'] = array_merge($this->_search['or'], $queries);
808    }
809
810    /**
811     * Search for messages modified since a specific moment. The IMAP server
812     * must support the CONDSTORE extension (RFC 7162) for this query to be
813     * used.
814     *
815     * @param integer $value  The mod-sequence value.
816     * @param string $name    The entry-name string.
817     * @param string $type    Either 'shared', 'priv', or 'all'. Defaults to
818     *                        'all'
819     * @param boolean $not    If true, do a 'NOT' search.
820     * @param array $opts     Additional options:
821     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
822     *            MUST support RFC 6203.
823     */
824    public function modseq($value, $name = null, $type = null, $not = false,
825                           array $opts = array())
826    {
827        if (!is_null($type)) {
828            $type = Horde_String::lower($type);
829            if (!in_array($type, array('shared', 'priv', 'all'))) {
830                $type = 'all';
831            }
832        }
833
834        $this->_search['modseq'] = array_filter(array(
835            'fuzzy' => !empty($opts['fuzzy']),
836            'name' => $name,
837            'not' => $not,
838            'type' => (!is_null($name) && is_null($type)) ? 'all' : $type,
839            'value' => $value
840        ));
841    }
842
843    /**
844     * Use the results from the previous SEARCH command. The IMAP server must
845     * support the SEARCHRES extension (RFC 5182) for this query to be used.
846     *
847     * @param boolean $not  If true, don't match the previous query.
848     * @param array $opts   Additional options:
849     *   - fuzzy: (boolean) If true, perform a fuzzy search. The IMAP server
850     *            MUST support RFC 6203.
851     */
852    public function previousSearch($not = false, array $opts = array())
853    {
854        $this->_search['prevsearch'] = $not;
855        if (!empty($opts['fuzzy'])) {
856            $this->_search['prevsearchfuzzy'] = true;
857        }
858    }
859
860    /* Serializable methods. */
861
862    /**
863     * Serialization.
864     *
865     * @return string  Serialized data.
866     */
867    public function serialize()
868    {
869        $data = array(
870            // Serialized data ID.
871            self::VERSION,
872            $this->_search
873        );
874
875        if (!is_null($this->_charset)) {
876            $data[] = $this->_charset;
877        }
878
879        return serialize($data);
880    }
881
882    /**
883     * Unserialization.
884     *
885     * @param string $data  Serialized data.
886     *
887     * @throws Exception
888     */
889    public function unserialize($data)
890    {
891        $data = @unserialize($data);
892        if (!is_array($data) ||
893            !isset($data[0]) ||
894            ($data[0] != self::VERSION)) {
895            throw new Exception('Cache version change');
896        }
897
898        $this->_search = $data[1];
899        if (isset($data[2])) {
900            $this->_charset = $data[2];
901        }
902    }
903
904}
905