1: <?php
2: /**
3: * Utility functions for the Horde IMAP client.
4: *
5: * Copyright 2008-2012 Horde LLC (http://www.horde.org/)
6: *
7: * getBaseSubject() code adapted from imap-base-subject.c (Dovecot 1.2)
8: * Original code released under the LGPL-2.0.1
9: * Copyright (c) 2002-2008 Timo Sirainen <tss@iki.fi>
10: *
11: * See the enclosed file COPYING for license information (LGPL). If you
12: * did not receive this file, see http://www.horde.org/licenses/lgpl21.
13: *
14: * @author Michael Slusarz <slusarz@horde.org>
15: * @category Horde
16: * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
17: * @package Imap_Client
18: */
19: class Horde_Imap_Client_Utils
20: {
21: /**
22: * Create an IMAP message sequence string from a list of indices.
23: *
24: * Index Format: range_start:range_end,uid,uid2,...
25: *
26: * Mailbox Format: {mbox_length}[mailbox]range_start:range_end,uid,uid2,...
27: *
28: * @param mixed $in An array of indices (or a single index). See
29: * 'mailbox' below.
30: * @param array $options Additional options:
31: * - mailbox: (boolean) If true, store mailbox information with the
32: * ID list. $in should be an array of arrays, with keys as
33: * mailbox names and values as IDs.
34: * DEFAULT: false
35: * - nosort: (boolean) Do not numerically sort the IDs before creating
36: * the range?
37: * DEFAULT: false
38: *
39: * @return string The IMAP message sequence string.
40: */
41: public function toSequenceString($in, $options = array())
42: {
43: if (empty($in)) {
44: return '';
45: }
46:
47: if (!empty($options['mailbox'])) {
48: $str = '';
49: unset($options['mailbox']);
50:
51: foreach ($in as $mbox => $ids) {
52: $str .= '{' . strlen($mbox) . '}' . $mbox . $this->toSequenceString($ids, $options);
53: }
54:
55: return $str;
56: }
57:
58: // Make sure IDs are unique
59: $in = is_array($in)
60: ? array_keys(array_flip($in))
61: : array($in);
62:
63: if (empty($options['nosort'])) {
64: sort($in, SORT_NUMERIC);
65: }
66:
67: $first = $last = array_shift($in);
68: $i = count($in) - 1;
69: $out = array();
70:
71: reset($in);
72: while (list($key, $val) = each($in)) {
73: if (($last + 1) == $val) {
74: $last = $val;
75: }
76:
77: if (($i == $key) || ($last != $val)) {
78: if ($last == $first) {
79: $out[] = $first;
80: if ($i == $key) {
81: $out[] = $val;
82: }
83: } else {
84: $out[] = $first . ':' . $last;
85: if (($i == $key) && ($last != $val)) {
86: $out[] = $val;
87: }
88: }
89: $first = $last = $val;
90: }
91: }
92:
93: return empty($out)
94: ? $first
95: : implode(',', $out);
96: }
97:
98: /**
99: * Parse an IMAP message sequence string into a list of indices.
100: * See toSequenceString() for allowed formats.
101: *
102: * @see toSequenceString()
103: *
104: * @param string $str The IMAP message sequence string.
105: *
106: * @return array An array of indices. If string contains mailbox info,
107: * return value will be an array of arrays, with keys as
108: * mailbox names and values as IDs. Otherwise, return the
109: * list of IDs.
110: */
111: public function fromSequenceString($str)
112: {
113: $ids = array();
114: $str = trim($str);
115:
116: if (!strlen($str)) {
117: return $ids;
118: }
119:
120: if ($str[0] == '{') {
121: $i = strpos($str, '}');
122: $count = intval(substr($str, 1, $i - 1));
123: $mbox = substr($str, $i + 1, $count);
124: $i += $count + 1;
125: $end = strpos($str, '{', $i);
126:
127: if ($end === false) {
128: $uidstr = substr($str, $i);
129: } else {
130: $uidstr = substr($str, $i, $end - $i);
131: $ids = $this->fromSequenceString(substr($str, $end));
132: }
133:
134: $ids[$mbox] = $this->fromSequenceString($uidstr);
135:
136: return $ids;
137: }
138:
139: $idarray = explode(',', $str);
140:
141: reset($idarray);
142: while (list(,$val) = each($idarray)) {
143: $range = explode(':', $val);
144: if (isset($range[1])) {
145: for ($i = min($range), $j = max($range); $i <= $j; ++$i) {
146: $ids[] = $i;
147: }
148: } else {
149: $ids[] = $val;
150: }
151: }
152:
153: return $ids;
154: }
155:
156: /**
157: * Remove "bare newlines" from a string.
158: *
159: * @param string $str The original string.
160: *
161: * @return string The string with all bare newlines removed.
162: */
163: public function removeBareNewlines($str)
164: {
165: return str_replace(array("\r\n", "\n"), array("\n", "\r\n"), $str);
166: }
167:
168: /**
169: * Escape IMAP output via a quoted string (see RFC 3501 [4.3]). Note that
170: * IMAP quoted strings support 7-bit characters only and can not contain
171: * either CR or LF.
172: *
173: * @param string $str The unescaped string.
174: * @param boolean $force Always add quotes?
175: *
176: * @return string The escaped string.
177: */
178: public function escape($str, $force = false)
179: {
180: if (!strlen($str)) {
181: return '""';
182: }
183:
184: $newstr = addcslashes($str, '"\\');
185: return (!$force && ($str == $newstr))
186: ? $str
187: : '"' . $newstr . '"';
188: }
189:
190: /**
191: * Given a string, will strip out any characters that are not allowed in
192: * the IMAP 'atom' definition (RFC 3501 [9]).
193: *
194: * @param string $str An ASCII string.
195: *
196: * @return string The string with the disallowed atom characters stripped
197: * out.
198: */
199: public function stripNonAtomChars($str)
200: {
201: return str_replace(array('(', ')', '{', ' ', '%', '*', '"', '\\', ']'), '', preg_replace('/[\x00-\x1f\x7f]/', '', $str));
202: }
203:
204: /**
205: * Return the "base subject" defined in RFC 5256 [2.1].
206: *
207: * @param string $str The original subject string.
208: * @param array $options Additional options:
209: * - keepblob: (boolean) Don't remove any "blob" information (i.e. text
210: * leading text between square brackets) from string.
211: *
212: * @return string The cleaned up subject string.
213: */
214: public function getBaseSubject($str, $options = array())
215: {
216: // Rule 1a: MIME decode to UTF-8.
217: $str = Horde_Mime::decode($str, 'UTF-8');
218:
219: // Rule 1b: Remove superfluous whitespace.
220: $str = preg_replace("/[\t\r\n ]+/", ' ', $str);
221:
222: if (!strlen($str)) {
223: return '';
224: }
225:
226: do {
227: /* (2) Remove all trailing text of the subject that matches the
228: * the subj-trailer ABNF, repeat until no more matches are
229: * possible. */
230: $str = preg_replace("/(?:\s*\(fwd\)\s*)+$/i", '', $str);
231:
232: do {
233: /* (3) Remove all prefix text of the subject that matches the
234: * subj-leader ABNF. */
235: $found = $this->_removeSubjLeader($str, !empty($options['keepblob']));
236:
237: /* (4) If there is prefix text of the subject that matches
238: * the subj-blob ABNF, and removing that prefix leaves a
239: * non-empty subj-base, then remove the prefix text. */
240: $found = (empty($options['keepblob']) && $this->_removeBlobWhenNonempty($str)) || $found;
241:
242: /* (5) Repeat (3) and (4) until no matches remain. */
243: } while ($found);
244:
245: /* (6) If the resulting text begins with the subj-fwd-hdr ABNF and
246: * ends with the subj-fwd-trl ABNF, remove the subj-fwd-hdr and
247: * subj-fwd-trl and repeat from step (2). */
248: } while ($this->_removeSubjFwdHdr($str));
249:
250: return $str;
251: }
252:
253: /**
254: * Parse a POP3 (RFC 2384) or IMAP (RFC 5092/5593) URL.
255: *
256: * Absolute IMAP URLs takes one of the following forms:
257: * - imap://<iserver>[/]
258: * - imap://<iserver>/<enc-mailbox>[<uidvalidity>][?<enc-search>]
259: * - imap://<iserver>/<enc-mailbox>[<uidvalidity>]<iuid>[<isection>][<ipartial>][<iurlauth>]
260: *
261: * POP URLs take one of the following forms:
262: * - pop://<user>;auth=<auth>@<host>:<port>
263: *
264: * @param string $url A URL string.
265: *
266: * @return mixed False if the URL is invalid. If valid, an array with
267: * the following fields:
268: * - auth: (string) The authentication method to use.
269: * - hostspec: (string) The remote server. (Not present for relative
270: * URLs).
271: * - mailbox: (string) The IMAP mailbox.
272: * - partial: (string) A byte range for use with IMAP FETCH.
273: * - port: (integer) The remote port. (Not present for relative URLs).
274: * - relative: (boolean) True if this is a relative URL.
275: * - search: (string) A search query to be run with IMAP SEARCH.
276: * - section: (string) A MIME part ID.
277: * - type: (string) Either 'imap' or 'pop'. (Not present for relative
278: * URLs).
279: * - username: (string) The username to use on the remote server.
280: * - uid: (string) The IMAP UID.
281: * - uidvalidity: (integer) The IMAP UIDVALIDITY for the given mailbox.
282: * - urlauth: (string) URLAUTH info (not parsed).
283: */
284: public function parseUrl($url)
285: {
286: $data = parse_url(trim($url));
287:
288: if (isset($data['scheme'])) {
289: $type = strtolower($data['scheme']);
290: if (!in_array($type, array('imap', 'pop'))) {
291: return false;
292: }
293: $relative = false;
294: } else {
295: $type = null;
296: $relative = true;
297: }
298:
299: $ret_array = array(
300: 'hostspec' => isset($data['host']) ? $data['host'] : null,
301: 'port' => isset($data['port']) ? $data['port'] : 143,
302: 'relative' => $relative,
303: 'type' => $type
304: );
305:
306: /* Check for username/auth information. */
307: if (isset($data['user'])) {
308: if (($pos = stripos($data['user'], ';AUTH=')) !== false) {
309: $auth = substr($data['user'], $pos + 6);
310: if ($auth != '*') {
311: $ret_array['auth'] = $auth;
312: }
313: $data['user'] = substr($data['user'], 0, $pos);
314: }
315:
316: if (strlen($data['user'])) {
317: $ret_array['username'] = $data['user'];
318: }
319: }
320:
321: /* IMAP-only information. */
322: if (!$type || ($type == 'imap')) {
323: if (isset($data['path'])) {
324: $data['path'] = ltrim($data['path'], '/');
325: $parts = explode('/;', $data['path']);
326:
327: $mbox = array_shift($parts);
328: if (($pos = stripos($mbox, ';UIDVALIDITY=')) !== false) {
329: $ret_array['uidvalidity'] = substr($mbox, $pos + 13);
330: $mbox = substr($mbox, 0, $pos);
331: }
332: $ret_array['mailbox'] = urldecode($mbox);
333:
334: }
335:
336: if (count($parts)) {
337: foreach ($parts as $val) {
338: list($k, $v) = explode('=', $val);
339: $ret_array[strtolower($k)] = $v;
340: }
341: } elseif (isset($data['query'])) {
342: $ret_array['search'] = urldecode($data['query']);
343: }
344: }
345:
346: return $ret_array;
347: }
348:
349: /**
350: * Create a POP3 (RFC 2384) or IMAP (RFC 5092/5593) URL.
351: *
352: * @param array $data The data used to create the URL. See the return
353: * value from parseUrl() for the available fields.
354: *
355: * @return string A URL string.
356: */
357: public function createUrl($data)
358: {
359: $url = '';
360:
361: if (isset($data['type'])) {
362: $url = $data['type'] . '://';
363:
364: if (isset($data['username'])) {
365: $url .= $data['username'];
366: }
367:
368: if (isset($data['auth'])) {
369: $url .= ';AUTH=' . $data['auth'] . '@';
370: } elseif (isset($data['username'])) {
371: $url .= '@';
372: }
373:
374: $url .= $data['hostspec'];
375:
376: if (isset($data['port']) && ($data['port'] != 143)) {
377: $url .= ':' . $data['port'];
378: }
379: }
380:
381: $url .= '/';
382:
383: if (!isset($data['type']) || ($data['type'] == 'imap')) {
384: $url .= urlencode($data['mailbox']);
385:
386: if (!empty($data['uidvalidity'])) {
387: $url .= ';UIDVALIDITY=' . $data['uidvalidity'];
388: }
389:
390: if (isset($data['search'])) {
391: $url .= '?' . urlencode($data['search']);
392: } else {
393: if (isset($data['uid'])) {
394: $url .= '/;UID=' . $data['uid'];
395: }
396:
397: if (isset($data['section'])) {
398: $url .= '/;SECTION=' . $data['section'];
399: }
400:
401: if (isset($data['partial'])) {
402: $url .= '/;PARTIAL=' . $data['partial'];
403: }
404:
405: if (isset($data['urlauth'])) {
406: $url .= '/;URLAUTH=' . $data['urlauth'];
407: }
408: }
409: }
410:
411:
412: return $url;
413: }
414:
415: /**
416: * Parses a client command array to create a server command string.
417: *
418: * @since 1.2.0
419: *
420: * @param string $out The unprocessed command string.
421: * @param callback $callback A callback function to use if literal data
422: * is found. Two arguments are passed: the
423: * command string (as built so far) and the
424: * literal data. The return value should be the
425: * new value for the current command string.
426: * @param array $query An array with the following format:
427: * <ul>
428: * <li>
429: * Array
430: * <ul>
431: * <li>
432: * Array with keys 't' and 'v'
433: * <ul>
434: * <li>t: IMAP data type (Horde_Imap_Client::DATA_* constants)</li>
435: * <li>v: Data value</li>
436: * </ul>
437: * </li>
438: * <li>
439: * Array with only values
440: * <ul>
441: * <li>Treated as a parenthesized list</li>
442: * </ul>
443: * </li>
444: * </ul>
445: * </li>
446: * <li>
447: * Null
448: * <ul>
449: * <li>Ignored</li>
450: * </ul>
451: * </li>
452: * <li>
453: * Resource
454: * <ul>
455: * <li>Treated as literal data</li>
456: * </ul>
457: * </li>
458: * <li>
459: * String
460: * <ul>
461: * <li>Output as-is (raw)</li>
462: * </ul>
463: * </li>
464: * </ul>
465: *
466: * @return string The command string.
467: */
468: public function parseCommandArray($query, $callback = null, $out = '')
469: {
470: foreach ($query as $val) {
471: if (is_null($val)) {
472: continue;
473: }
474:
475: if (is_array($val)) {
476: if (isset($val['t'])) {
477: if ($val['t'] == Horde_Imap_Client::DATA_NUMBER) {
478: $out .= intval($val['v']);
479: } elseif (($val['t'] != Horde_Imap_Client::DATA_ATOM) &&
480: preg_match('/[\x80-\xff\n\r]/', $val['v'])) {
481: if (is_callable($callback)) {
482: $out = call_user_func_array($callback, array($out, $val['v']));
483: }
484: } else {
485: switch ($val['t']) {
486: case Horde_Imap_Client::DATA_ASTRING:
487: case Horde_Imap_Client::DATA_MAILBOX:
488: /* Only requires quoting if an atom-special is
489: * present (besides resp-specials). */
490: $out .= $this->escape($val['v'], preg_match('/[\x00-\x1f\x7f\(\)\{\s%\*"\\\\]/', $val['v']));
491: break;
492:
493:
494: case Horde_Imap_Client::DATA_ATOM:
495: $out .= $val['v'];
496: break;
497:
498: case Horde_Imap_Client::DATA_STRING:
499: /* IMAP strings MUST be quoted. */
500: $out .= $this->escape($val['v'], true);
501: break;
502:
503: case Horde_Imap_Client::DATA_DATETIME:
504: $out .= '"' . $val['v'] . '"';
505: break;
506:
507: case Horde_Imap_Client::DATA_LISTMAILBOX:
508: $out .= $this->escape($val['v'], preg_match('/[\x00-\x1f\x7f\(\)\{\s"\\\\]/', $val['v']));
509: break;
510:
511: case Horde_Imap_Client::DATA_NSTRING:
512: $out .= strlen($val['v'])
513: ? $this->escape($val['v'], true)
514: : 'NIL';
515: break;
516: }
517: }
518: } else {
519: $out = rtrim($this->parseCommandArray($val, $callback, $out . '(')) . ')';
520: }
521:
522: $out .= ' ';
523: } elseif (is_resource($val)) {
524: /* Resource indicates literal data. */
525: if (is_callable($callback)) {
526: $out = call_user_func_array($callback, array($out, $val)) . ' ';
527: }
528: } else {
529: $out .= $val . ' ';
530: }
531: }
532:
533: return $out;
534: }
535:
536: /* Internal methods. */
537:
538: /**
539: * Remove all prefix text of the subject that matches the subj-leader
540: * ABNF.
541: *
542: * @param string &$str The subject string.
543: * @param boolean $keepblob Remove blob information?
544: *
545: * @return boolean True if string was altered.
546: */
547: protected function _removeSubjLeader(&$str, $keepblob = false)
548: {
549: $ret = false;
550:
551: if (!strlen($str)) {
552: return $ret;
553: }
554:
555: if ($len = strspn($str, " \t")) {
556: $str = substr($str, $len);
557: $ret = true;
558: }
559:
560: $i = 0;
561:
562: if (!$keepblob) {
563: while (isset($str[$i]) && ($str[$i] == '[')) {
564: if (($i = $this->_removeBlob($str, $i)) === false) {
565: return $ret;
566: }
567: }
568: }
569:
570: if (stripos($str, 're', $i) === 0) {
571: $i += 2;
572: } elseif (stripos($str, 'fwd', $i) === 0) {
573: $i += 3;
574: } elseif (stripos($str, 'fw', $i) === 0) {
575: $i += 2;
576: } else {
577: return $ret;
578: }
579:
580: $i += strspn($str, " \t", $i);
581:
582: if (!$keepblob) {
583: while (isset($str[$i]) && ($str[$i] == '[')) {
584: if (($i = $this->_removeBlob($str, $i)) === false) {
585: return $ret;
586: }
587: }
588: }
589:
590: if (!isset($str[$i]) || ($str[$i] != ':')) {
591: return $ret;
592: }
593:
594: $str = substr($str, ++$i);
595:
596: return true;
597: }
598:
599: /**
600: * Remove "[...]" text.
601: *
602: * @param string &$str The subject string.
603: *
604: * @return boolean True if string was altered.
605: */
606: protected function _removeBlob($str, $i)
607: {
608: if ($str[$i] != '[') {
609: return false;
610: }
611:
612: ++$i;
613:
614: for ($cnt = strlen($str); $i < $cnt; ++$i) {
615: if ($str[$i] == ']') {
616: break;
617: }
618:
619: if ($str[$i] == '[') {
620: return false;
621: }
622: }
623:
624: if ($i == ($cnt - 1)) {
625: return false;
626: }
627:
628: ++$i;
629:
630: if ($str[$i] == ' ') {
631: ++$i;
632: }
633:
634: return $i;
635: }
636:
637: /**
638: * Remove "[...]" text if it doesn't result in the subject becoming
639: * empty.
640: *
641: * @param string &$str The subject string.
642: *
643: * @return boolean True if string was altered.
644: */
645: protected function _removeBlobWhenNonempty(&$str)
646: {
647: if ($str &&
648: ($str[0] == '[') &&
649: (($i = $this->_removeBlob($str, 0)) !== false) &&
650: ($i != strlen($str))) {
651: $str = substr($str, $i);
652: return true;
653: }
654:
655: return false;
656: }
657:
658: /**
659: * Remove a "[fwd: ... ]" string.
660: *
661: * @param string &$str The subject string.
662: *
663: * @return boolean True if string was altered.
664: */
665: protected function _removeSubjFwdHdr(&$str)
666: {
667: if ((stripos($str, '[fwd:') !== 0) || (substr($str, -1) != ']')) {
668: return false;
669: }
670:
671: $str = substr($str, 5, -1);
672: return true;
673: }
674:
675: }
676: