Minor fix and clarify return types
[squirrelmail.git] / functions / imap_asearch.php
1 <?php
2
3 /**
4 * imap_search.php
5 *
6 * IMAP asearch routines
7 *
8 * Subfolder search idea from Patch #806075 by Thomas Pohl xraven at users.sourceforge.net. Thanks Thomas!
9 *
10 * @author Alex Lemaresquier - Brainstorm <alex at brainstorm.fr>
11 * @copyright 1999-2017 The SquirrelMail Project Team
12 * @license http://opensource.org/licenses/gpl-license.php GNU Public License
13 * @version $Id$
14 * @package squirrelmail
15 * @subpackage imap
16 * @see search.php
17 * @link http://www.ietf.org/rfc/rfc3501.txt
18 */
19
20 /** This functionality requires the IMAP and date functions
21 */
22 //require_once(SM_PATH . 'functions/imap_general.php');
23 //require_once(SM_PATH . 'functions/date.php');
24
25 /** Set to TRUE to dump the IMAP dialogue
26 * @global bool $imap_asearch_debug_dump
27 */
28 $imap_asearch_debug_dump = FALSE;
29
30 /** IMAP SEARCH keys
31 * @global array $imap_asearch_opcodes
32 */
33 global $imap_asearch_opcodes;
34 $imap_asearch_opcodes = array(
35 /* <sequence-set> => 'asequence', */ // Special handling, @see sqimap_asearch_build_criteria()
36 /*'ALL' is binary operator */
37 'ANSWERED' => '',
38 'BCC' => 'astring',
39 'BEFORE' => 'adate',
40 'BODY' => 'astring',
41 'CC' => 'astring',
42 'DELETED' => '',
43 'DRAFT' => '',
44 'FLAGGED' => '',
45 'FROM' => 'astring',
46 'HEADER' => 'afield', // Special syntax for this one, @see sqimap_asearch_build_criteria()
47 'KEYWORD' => 'akeyword',
48 'LARGER' => 'anum',
49 'NEW' => '',
50 /*'NOT' is unary operator */
51 'OLD' => '',
52 'ON' => 'adate',
53 /*'OR' is binary operator */
54 'RECENT' => '',
55 'SEEN' => '',
56 'SENTBEFORE' => 'adate',
57 'SENTON' => 'adate',
58 'SENTSINCE' => 'adate',
59 'SINCE' => 'adate',
60 'SMALLER' => 'anum',
61 'SUBJECT' => 'astring',
62 'TEXT' => 'astring',
63 'TO' => 'astring',
64 'UID' => 'asequence',
65 'UNANSWERED' => '',
66 'UNDELETED' => '',
67 'UNDRAFT' => '',
68 'UNFLAGGED' => '',
69 'UNKEYWORD' => 'akeyword',
70 'UNSEEN' => ''
71 );
72
73 /** IMAP SEARCH month names encoding
74 * @global array $imap_asearch_months
75 */
76 $imap_asearch_months = array(
77 '01' => 'jan',
78 '02' => 'feb',
79 '03' => 'mar',
80 '04' => 'apr',
81 '05' => 'may',
82 '06' => 'jun',
83 '07' => 'jul',
84 '08' => 'aug',
85 '09' => 'sep',
86 '10' => 'oct',
87 '11' => 'nov',
88 '12' => 'dec'
89 );
90
91 /**
92 * Function to display an error related to an IMAP query.
93 * We need to do our own error management since we may receive NO responses on purpose (even BAD with SORT or THREAD)
94 * so we call sqimap_error_box() if the function exists (sm >= 1.5) or use our own embedded code
95 * @global array imap_error_titles
96 * @param string $response the imap server response code
97 * @param string $query the failed query
98 * @param string $message an optional error message
99 * @param string $link an optional link to try again
100 */
101 //@global array color sm colors array
102 function sqimap_asearch_error_box($response, $query, $message, $link = '')
103 {
104 global $color;
105 // Error message titles according to IMAP server returned code
106 $imap_error_titles = array(
107 'OK' => '',
108 'NO' => _("ERROR: Could not complete request."),
109 'BAD' => _("ERROR: Bad or malformed request."),
110 'BYE' => _("ERROR: IMAP server closed the connection."),
111 '' => _("ERROR: Connection dropped by IMAP server.")
112 );
113
114
115 if (!array_key_exists($response, $imap_error_titles))
116 $title = _("ERROR: Unknown IMAP response.");
117 else
118 $title = $imap_error_titles[$response];
119 if ($link == '')
120 $message_title = _("Reason Given:");
121 else
122 $message_title = _("Possible reason:");
123 $message_title .= ' ';
124 sqimap_error_box($title, $query, $message_title, $message, $link);
125 }
126
127 /**
128 * This is a convenient way to avoid spreading if (isset(... all over the code
129 * @param mixed $var any variable (reference)
130 * @param mixed $def default value to return if unset (default is zls (''), pass 0 or array() when appropriate)
131 * @return mixed $def if $var is unset, otherwise $var
132 */
133 function asearch_nz(&$var, $def = '')
134 {
135 if (isset($var))
136 return $var;
137 return $def;
138 }
139
140 /**
141 * This should give the same results as PHP 4 >= 4.3.0's html_entity_decode(),
142 * except it doesn't handle hex constructs
143 * @param string $string string to unhtmlentity()
144 * @return string decoded string
145 */
146 function asearch_unhtmlentities($string) {
147 $trans_tbl = array_flip(get_html_translation_table(HTML_ENTITIES));
148 for ($i=127; $i<255; $i++) /* Add &#<dec>; entities */
149 $trans_tbl['&#' . $i . ';'] = chr($i);
150 return strtr($string, $trans_tbl);
151 /* I think the one above is quicker, though it should be benchmarked
152 $string = strtr($string, array_flip(get_html_translation_table(HTML_ENTITIES)));
153 return preg_replace("/&#([0-9]+);/E", "chr('\\1')", $string);
154 */
155 }
156
157 /** Encode a string to quoted or literal as defined in rfc 3501
158 *
159 * - 4.3 String:
160 * A quoted string is a sequence of zero or more 7-bit characters,
161 * excluding CR and LF, with double quote (<">) characters at each end.
162 * - 9. Formal Syntax:
163 * quoted-specials = DQUOTE / "\"
164 * @param string $what string to encode
165 * @param string $charset search charset used
166 * @return string encoded string
167 */
168 function sqimap_asearch_encode_string($what, $charset)
169 {
170 if (strtoupper($charset) == 'ISO-2022-JP') // This should be now handled in imap_utf7_local?
171 $what = mb_convert_encoding($what, 'JIS', 'auto');
172 if (preg_match('/["\\\\\r\n\x80-\xff]/', $what))
173 return '{' . strlen($what) . "}\r\n" . $what; // 4.3 literal form
174 return '"' . $what . '"'; // 4.3 quoted string form
175 }
176
177 /**
178 * Parses a user date string into an rfc 3501 date string
179 * Handles space, slash, backslash, dot and comma as separators (and dash of course ;=)
180 * @global array imap_asearch_months
181 * @param string user date
182 * @return array a preg_match-style array:
183 * - [0] = fully formatted rfc 3501 date string (<day number>-<US month TLA>-<4 digit year>)
184 * - [1] = day
185 * - [2] = month
186 * - [3] = year
187 */
188 function sqimap_asearch_parse_date($what)
189 {
190 global $imap_asearch_months;
191
192 $what = trim($what);
193 $what = preg_replace('/[ \/\\.,]+/', '-', $what);
194 if ($what) {
195 preg_match('/^([0-9]+)-+([^\-]+)-+([0-9]+)$/', $what, $what_parts);
196 if (count($what_parts) == 4) {
197 $what_month = strtolower(asearch_unhtmlentities($what_parts[2]));
198 /* if (!in_array($what_month, $imap_asearch_months)) {*/
199 foreach ($imap_asearch_months as $month_number => $month_code) {
200 if (($what_month == $month_number)
201 || ($what_month == $month_code)
202 || ($what_month == strtolower(asearch_unhtmlentities(getMonthName($month_number))))
203 || ($what_month == strtolower(asearch_unhtmlentities(getMonthAbrv($month_number))))
204 ) {
205 $what_parts[2] = $month_number;
206 $what_parts[0] = $what_parts[1] . '-' . $month_code . '-' . $what_parts[3];
207 break;
208 }
209 }
210 /* }*/
211 }
212 }
213 else
214 $what_parts = array();
215 return $what_parts;
216 }
217
218 /**
219 * Build one criteria sequence
220 * @global array imap_asearch_opcodes
221 * @param string $opcode search opcode
222 * @param string $what opcode argument
223 * @param string $charset search charset
224 * @return string one full criteria sequence
225 */
226 function sqimap_asearch_build_criteria($opcode, $what, $charset)
227 {
228 global $imap_asearch_opcodes;
229
230 $criteria = '';
231 switch ($imap_asearch_opcodes[$opcode]) {
232 default:
233 case 'anum':
234 $what = str_replace(' ', '', $what);
235 $what = preg_replace('/[^0-9]+[^KMG]$/', '', strtoupper($what));
236 if ($what != '') {
237 switch (substr($what, -1)) {
238 case 'G':
239 $what = substr($what, 0, -1) << 30;
240 break;
241 case 'M':
242 $what = substr($what, 0, -1) << 20;
243 break;
244 case 'K':
245 $what = substr($what, 0, -1) << 10;
246 break;
247 }
248 $criteria = $opcode . ' ' . $what . ' ';
249 }
250 break;
251 case '': //aflag
252 $criteria = $opcode . ' ';
253 break;
254 case 'afield': /* HEADER field-name: field-body */
255 preg_match('/^([^:]+):(.*)$/', $what, $what_parts);
256 if (count($what_parts) == 3)
257 $criteria = $opcode . ' ' .
258 sqimap_asearch_encode_string($what_parts[1], $charset) . ' ' .
259 sqimap_asearch_encode_string($what_parts[2], $charset) . ' ';
260 break;
261 case 'adate':
262 $what_parts = sqimap_asearch_parse_date($what);
263 if (isset($what_parts[0]))
264 $criteria = $opcode . ' ' . $what_parts[0] . ' ';
265 break;
266 case 'akeyword':
267 case 'astring':
268 $criteria = $opcode . ' ' . sqimap_asearch_encode_string($what, $charset) . ' ';
269 break;
270 case 'asequence':
271 $what = preg_replace('/[^0-9:()]+/', '', $what);
272 if ($what != '')
273 $criteria = $opcode . ' ' . $what . ' ';
274 break;
275 }
276 return $criteria;
277 }
278
279 /**
280 * Another way to do array_values(array_unique(array_merge($to, $from)));
281 * @param array $to to array (reference)
282 * @param array $from from array
283 * @return array uniquely merged array
284 */
285 function sqimap_array_merge_unique(&$to, $from)
286 {
287 if (empty($to))
288 return $from;
289 $count = count($from);
290 for ($i = 0; $i < $count; $i++) {
291 if (!in_array($from[$i], $to))
292 $to[] = $from[$i];
293 }
294 return $to;
295 }
296
297 /**
298 * Run the IMAP SEARCH command as defined in rfc 3501
299 * @link http://www.ietf.org/rfc/rfc3501.txt
300 * @param resource $imapConnection the current imap stream
301 * @param string $search_string the full search expression eg "ALL RECENT"
302 * @param string $search_charset charset to use or zls ('')
303 * @return array an IDs or UIDs array of matching messages or an empty array
304 * @since 1.5.0
305 */
306 function sqimap_run_search($imapConnection, $search_string, $search_charset)
307 {
308 //For some reason, this seems to happen and forbids searching servers not allowing OPTIONAL [CHARSET]
309 if (strtoupper($search_charset) == 'US-ASCII')
310 $search_charset = '';
311 /* 6.4.4 try OPTIONAL [CHARSET] specification first */
312 if ($search_charset != '')
313 $query = 'SEARCH CHARSET "' . strtoupper($search_charset) . '" ' . $search_string;
314 else
315 $query = 'SEARCH ' . $search_string;
316 $readin = sqimap_run_command_list($imapConnection, $query, false, $response, $message, TRUE);
317
318 /* 6.4.4 try US-ASCII charset if we tried an OPTIONAL [CHARSET] and received a tagged NO response (SHOULD be [BADCHARSET]) */
319 if (($search_charset != '') && (strtoupper($response) == 'NO')) {
320 $query = 'SEARCH CHARSET US-ASCII ' . $search_string;
321 $readin = sqimap_run_command_list($imapConnection, $query, false, $response, $message, TRUE);
322 }
323 if (strtoupper($response) != 'OK') {
324 sqimap_asearch_error_box($response, $query, $message);
325 return array();
326 }
327 $messagelist = parseUidList($readin,'SEARCH');
328
329 if (empty($messagelist)) //Empty search response, ie '* SEARCH'
330 return array();
331
332 $cnt = count($messagelist);
333 for ($q = 0; $q < $cnt; $q++)
334 $id[$q] = trim($messagelist[$q]);
335 return $id;
336 }
337
338 /**
339 * @global bool allow_charset_search user setting
340 * @global array languages sm languages array
341 * @global string squirrelmail_language user language setting
342 * @return string the user defined charset if $allow_charset_search is TRUE else zls ('')
343 */
344 function sqimap_asearch_get_charset()
345 {
346 global $allow_charset_search, $languages, $squirrelmail_language;
347
348 if ($allow_charset_search)
349 return $languages[$squirrelmail_language]['CHARSET'];
350 return '';
351 }
352
353 /**
354 * Convert SquirrelMail internal sort to IMAP sort taking care of:
355 * - user defined date sorting (ARRIVAL vs DATE)
356 * - if the searched mailbox is the sent folder then TO is being used instead of FROM
357 * - reverse order by using REVERSE
358 * @param string $mailbox mailbox name to sort
359 * @param integer $sort_by sm sort criteria index
360 * @global bool internal_date_sort sort by arrival date instead of message date
361 * @global string sent_folder sent folder name
362 * @return string imap sort criteria
363 */
364 function sqimap_asearch_get_sort_criteria($mailbox, $sort_by)
365 {
366 global $internal_date_sort, $sent_folder;
367
368 $sort_opcodes = array ('DATE', 'FROM', 'SUBJECT', 'SIZE');
369 if ($internal_date_sort == true)
370 $sort_opcodes[0] = 'ARRIVAL';
371 // FIXME: Why are these commented out? I have no idea what this code does, but both of these functions sound more robust than the simple string check that's being used now. Someone who understands this code should either fix this or remove these lines completely or document why they are here commented out
372 // if (handleAsSent($mailbox))
373 // if (isSentFolder($mailbox))
374 if ($mailbox == $sent_folder)
375 $sort_opcodes[1] = 'TO';
376 return (($sort_by % 2) ? '' : 'REVERSE ') . $sort_opcodes[($sort_by >> 1) & 3];
377 }
378
379 /**
380 * @param string $cur_mailbox unformatted mailbox name
381 * @param array $boxes_unformatted selectable mailbox unformatted names array (reference)
382 * @return array sub mailboxes unformatted names
383 */
384 function sqimap_asearch_get_sub_mailboxes($cur_mailbox, &$mboxes_array)
385 {
386 $sub_mboxes_array = array();
387 $boxcount = count($mboxes_array);
388 for ($boxnum=0; $boxnum < $boxcount; $boxnum++) {
389 if (isBoxBelow($mboxes_array[$boxnum], $cur_mailbox))
390 $sub_mboxes_array[] = $mboxes_array[$boxnum];
391 }
392 return $sub_mboxes_array;
393 }
394
395 /**
396 * Create the search query strings for all given criteria and merge results for every mailbox
397 * @param resource $imapConnection
398 * @param array $mailbox_array (reference)
399 * @param array $biop_array (reference)
400 * @param array $unop_array (reference)
401 * @param array $where_array (reference)
402 * @param array $what_array (reference)
403 * @param array $exclude_array (reference)
404 * @param array $sub_array (reference)
405 * @param array $mboxes_array selectable unformatted mailboxes names (reference)
406 * @return array array(mailbox => array(UIDs))
407 */
408 function sqimap_asearch($imapConnection, &$mailbox_array, &$biop_array, &$unop_array, &$where_array, &$what_array, &$exclude_array, &$sub_array, &$mboxes_array)
409 {
410
411 $search_charset = sqimap_asearch_get_charset();
412 $mbox_search = array();
413 $search_string = '';
414 $cur_mailbox = $mailbox_array[0];
415 $cur_biop = ''; /* Start with ALL */
416 /* We loop one more time than the real array count, so the last search gets fired */
417 for ($cur_crit=0,$iCnt=count($where_array); $cur_crit <= $iCnt; ++$cur_crit) {
418 if (empty($exclude_array[$cur_crit])) {
419 $next_mailbox = (isset($mailbox_array[$cur_crit])) ? $mailbox_array[$cur_crit] : false;
420 if ($next_mailbox != $cur_mailbox) {
421 $search_string = trim($search_string); /* Trim out last space */
422 if ($cur_mailbox == 'All Folders')
423 $search_mboxes = $mboxes_array;
424 else if ((!empty($sub_array[$cur_crit - 1])) || (!in_array($cur_mailbox, $mboxes_array)))
425 $search_mboxes = sqimap_asearch_get_sub_mailboxes($cur_mailbox, $mboxes_array);
426 else
427 $search_mboxes = array($cur_mailbox);
428 foreach ($search_mboxes as $cur_mailbox) {
429 if (isset($mbox_search[$cur_mailbox])) {
430 $mbox_search[$cur_mailbox]['search'] .= ' ' . $search_string;
431 } else {
432 $mbox_search[$cur_mailbox]['search'] = $search_string;
433 }
434 $mbox_search[$cur_mailbox]['charset'] = $search_charset;
435 }
436 $cur_mailbox = $next_mailbox;
437 $search_string = '';
438 }
439 if (isset($where_array[$cur_crit]) && empty($exclude_array[$cur_crit])) {
440 $aCriteria = array();
441 for ($crit = $cur_crit; $crit < count($where_array); $crit++) {
442 $criteria = trim(sqimap_asearch_build_criteria($where_array[$crit], $what_array[$crit], $search_charset));
443 if (!empty($criteria) && empty($exclude_array[$crit])) {
444 if (asearch_nz($mailbox_array[$crit]) == $cur_mailbox) {
445 $unop = $unop_array[$crit];
446 if (!empty($unop)) {
447 $criteria = $unop . ' ' . $criteria;
448 }
449 $aCriteria[] = array($biop_array[$crit], $criteria);
450 }
451 }
452 // unset something
453 $exclude_array[$crit] = true;
454 }
455 $aSearch = array();
456 for($i=0,$iCnt=count($aCriteria);$i<$iCnt;++$i) {
457 $cur_biop = $aCriteria[$i][0];
458 $next_biop = (isset($aCriteria[$i+1][0])) ? $aCriteria[$i+1][0] : false;
459 if ($next_biop != $cur_biop && $next_biop == 'OR') {
460 $aSearch[] = 'OR '.$aCriteria[$i][1];
461 } else if ($cur_biop != 'OR') {
462 $aSearch[] = 'ALL '.$aCriteria[$i][1];
463 } else { // OR only supports 2 search keys so we need to create a parenthesized list
464 $prev_biop = (isset($aCriteria[$i-1][0])) ? $aCriteria[$i-1][0] : false;
465 if ($prev_biop == $cur_biop) {
466 $last = $aSearch[$i-1];
467 if (!substr($last,-1) == ')') {
468 $aSearch[$i-1] = "(OR $last";
469 $aSearch[] = $aCriteria[$i][1].')';
470 } else {
471 $sEnd = '';
472 while ($last && substr($last,-1) == ')') {
473 $last = substr($last,0,-1);
474 $sEnd .= ')';
475 }
476 $aSearch[$i-1] = "(OR $last";
477 $aSearch[] = $aCriteria[$i][1].$sEnd.')';
478 }
479 } else {
480 $aSearch[] = $aCriteria[$i][1];
481 }
482 }
483 }
484 $search_string .= implode(' ',$aSearch);
485 }
486 }
487 }
488 return ($mbox_search);
489 }