1: <?php
2:
3: /**
4: * IMAP modules
5: * @package modules
6: * @subpackage imap
7: */
8:
9:
10: /**
11: * Base class for a generic PHP5 IMAP client library.
12: * This code is derived from the IMAP library used in Hastymail2 (www.hastymail.org)
13: * and is covered by the same license restrictions (GPL2)
14: * @subpackage imap/lib
15: */
16: class Hm_IMAP_Base {
17:
18: public $cached_response = false; // flag to indicate we are using a cached response
19: public $supported_extensions = array(); // IMAP extensions in the CAPABILITY response
20: protected $handle = false; // fsockopen handle to the IMAP server
21: protected $debug = array(); // debug messages
22: protected $commands = array(); // list of IMAP commands issued
23: protected $responses = array(); // list of raw IMAP responses
24: protected $current_command = false; // current/latest IMAP command issued
25: protected $max_read = false; // limit on allowable read size
26: protected $command_count = 0; // current command number
27: protected $cache_data = array(); // cache data
28: protected $enabled_extensions = array(); // IMAP extensions validated by the ENABLE response
29: protected $capability = false; // IMAP CAPABILITY response
30: protected $server_id = array(); // server ID response values
31: protected $literal_overflow = false;
32: public $struct_object = false;
33:
34:
35: /* attributes that can be set for the IMAP connaction */
36: protected $config = array('server', 'port', 'tls', 'read_only',
37: 'utf7_folders', 'auth', 'search_charset', 'sort_speedup', 'folder_max',
38: 'use_cache', 'max_history', 'blacklisted_extensions', 'app_name', 'app_version',
39: 'app_vendor', 'app_support_url', 'cache_limit', 'no_caps');
40:
41: /* supported extensions */
42: protected $client_extensions = array('SORT', 'COMPRESS', 'NAMESPACE', 'CONDSTORE',
43: 'ENABLE', 'QRESYNC', 'MOVE', 'SPECIAL-USE', 'LIST-STATUS', 'UNSELECT', 'ID', 'X-GM-EXT-1',
44: 'ESEARCH', 'ESORT', 'QUOTA', 'LIST-EXTENDED');
45:
46: /* extensions to declare with ENABLE */
47: protected $declared_extensions = array('CONDSTORE', 'QRESYNC');
48:
49: /**
50: * increment the imap command prefix such that it counts
51: * up on each command sent. ('A1', 'A2', ...)
52: * @return int new command count
53: */
54: private function command_number() {
55: $this->command_count += 1;
56: return $this->command_count;
57: }
58:
59: /**
60: * Read IMAP literal found during parse_line().
61: * NOTE: it is important to treat sizes and string functions
62: * in bytes here as literal size is specified in bytes (and not characters).
63: * @param int $size size of the IMAP literal to read
64: * @param int $max max size to allow
65: * @param int $current current size read
66: * @param int $line_length amount to read in using fgets()
67: * @return array the data read and any "left over" data
68: * that was inadvertantly on the same line as
69: * the last fgets result
70: */
71: private function read_literal($size, $max, $current, $line_length) {
72: $left_over = false;
73: $literal_data = $this->fgets($line_length);
74: $lit_size = strlen($literal_data);
75: $current += $lit_size;
76: while ($lit_size < $size) {
77: $chunk = $this->fgets($line_length);
78: $chunk_size = strlen($chunk);
79: $lit_size += $chunk_size;
80: $current += $chunk_size;
81: $literal_data .= $chunk;
82: if ($max && $current > $max) {
83: $this->max_read = true;
84: break;
85: }
86: }
87: if ($this->max_read) {
88: while ($lit_size < $size) {
89: $temp = $this->fgets($line_length);
90: $lit_size += strlen($temp);
91: }
92: }
93: elseif ($size < strlen($literal_data)) {
94: $left_over = substr($literal_data, $size);
95: $literal_data = substr($literal_data, 0, $size);
96: }
97: return array($literal_data, $left_over);
98: }
99:
100: /**
101: * break up a "line" response from imap. If we find
102: * a literal we read ahead on the stream and include it.
103: * @param string $line data read from the IMAP server
104: * @param int $current_size size of current read operation
105: * @param int $max maximum input size to allow
106: * @param int $line_length chunk size to read literals with
107: * @return array a line continuation marker and the parsed data
108: * from the IMAP server
109: */
110: protected function parse_line($line, $current_size, $max, $line_length) {
111: /* make it a bit easier to find "atoms" */
112: $line = str_replace(')(', ') (', $line);
113: $this->literal_overflow = false;
114:
115: /* will hold the line parts */
116: $parts = array();
117:
118: /* flag to control if the line continues */
119: $line_cont = false;
120:
121: /* line size */
122: $len = mb_strlen($line);
123:
124: /* walk through the line */
125: for ($i=0;$i<$len;$i++) {
126:
127: $char = mb_substr($line, $i, 1);
128:
129: /* this will hold one "atom" from the parsed line */
130: $chunk = '';
131:
132: /* if we hit a newline exit the loop */
133: if ($char == "\r" || $char == "\n") {
134: $line_cont = false;
135: break;
136: }
137:
138: /* skip spaces */
139: if ($char == ' ') {
140: continue;
141: }
142:
143: /* capture special chars as "atoms" */
144: elseif ($char == '*' || $char == '[' || $char == ']' || $char == '(' || $char == ')') {
145: $chunk = $char;
146: }
147:
148: /* regex match a quoted string */
149: elseif ($char == '"') {
150: if (preg_match("/^(\"[^\"\\\]*(?:\\\.[^\"\\\]*)*\")/", mb_substr($line, $i), $matches)) {
151: $chunk = mb_substr($matches[1], 1, -1);
152: }
153: $i += mb_strlen($chunk) + 1;
154: }
155:
156: /* IMAP literal */
157: elseif ($char == '{') {
158: $end = mb_strpos($line, '}');
159: if ($end !== false) {
160: $literal_size = mb_substr($line, ($i + 1), ($end - $i - 1));
161: }
162: $lit_result = $this->read_literal($literal_size, $max, $current_size, $line_length);
163: $chunk = $lit_result[0];
164: if (!isset($lit_result[1]) || $lit_result[1] != "\r\n") {
165: $line_cont = true;
166: }
167: if (isset($lit_result[1]) && $lit_result[1] != "\r\n" && mb_strlen($lit_result[1]) > 0) {
168: $this->literal_overflow = $lit_result[1];
169: }
170: $i = $len;
171: }
172:
173: /* all other atoms */
174: else {
175: $marker = -1;
176:
177: /* don't include these three trailing chars in the atom */
178: foreach (array(' ', ')', ']') as $v) {
179: $tmp_marker = mb_strpos($line, $v, $i);
180: if ($tmp_marker !== false && ($marker == -1 || $tmp_marker < $marker)) {
181: $marker = $tmp_marker;
182: }
183: }
184:
185: /* slice out the chunk */
186: if ($marker !== false && $marker !== -1) {
187: $chunk = mb_substr($line, $i, ($marker - $i));
188: $i += mb_strlen($chunk) - 1;
189: }
190: else {
191: $chunk = rtrim(mb_substr($line, $i));
192: $i += mb_strlen($chunk);
193: }
194: }
195:
196: /* if we found a worthwhile chunk add it to the results set */
197: if ($chunk) {
198: $parts[] = $chunk;
199: }
200: }
201: return array($line_cont, $parts);
202: }
203:
204: /**
205: * wrapper around fgets using $this->handle
206: * @param int $len max read length for fgets
207: * @return string data read from the IMAP server
208: */
209: protected function fgets($len=false) {
210: if (is_resource($this->handle) && !feof($this->handle)) {
211: if ($len) {
212: return fgets($this->handle, $len);
213: }
214: else {
215: return fgets($this->handle);
216: }
217: }
218: return '';
219: }
220:
221: /**
222: * loop through "lines" returned from imap and parse them with parse_line() and read_literal.
223: * it can return the lines in a raw format, or parsed into atoms. It also supports a maximum
224: * number of lines to return, in case we did something stupid like list a loaded unix homedir
225: * used by scram lib, so keep it public
226: * @param int $max max size of response allowed
227: * @param bool $chunked flag to parse the data into IMAP "atoms"
228: * @param int $line_length chunk size to read in literals using fgets
229: * @param bool $sort flag for non-compliant sort result parsing speed up
230: * @return array of parsed or raw results
231: */
232: public function get_response($max=false, $chunked=false, $line_length=8192, $sort=false) {
233: /* defaults and results containers */
234: $result = array();
235: $current_size = 0;
236: $chunked_result = array();
237: $last_line_cont = false;
238: $line_cont = false;
239: $c = -1;
240: $n = -1;
241:
242: /* start of do -> while loop to read from the IMAP server */
243: do {
244: $n++;
245:
246: /* if we loose connection to the server while reading terminate */
247: if (Hm_Functions::stream_ended($this->handle)) {
248: break;
249: }
250:
251: /* read in a line up to 8192 bytes */
252: $result[$n] = $this->fgets($line_length);
253:
254: /* keep track of how much we have read and break out if we max out. This can
255: * happen on large messages. We need this check to ensure we don't exhaust available
256: * memory */
257: $current_size += mb_strlen($result[$n]);
258: if ($max && $current_size > $max) {
259: $this->max_read = true;
260: break;
261: }
262:
263: /* if the line is longer than 8192 bytes keep appending more reads until we find
264: * an end of line char. Keep checking the max read length as we go */
265: while(mb_substr($result[$n], -2) != "\r\n" && mb_substr($result[$n], -1) != "\n") {
266: if (!is_resource($this->handle) || feof($this->handle)) {
267: break;
268: }
269: $result[$n] .= $this->fgets($line_length);
270: if ($result[$n] === false) {
271: break;
272: }
273: $current_size += mb_strlen($result[$n]);
274: if ($max && $current_size > $max) {
275: $this->max_read = true;
276: break 2;
277: }
278: }
279:
280: /* check line continuation marker and grab previous index and parsed chunks */
281: if ($line_cont) {
282: $last_line_cont = true;
283: $pres = $n - 1;
284: if ($chunks) {
285: $pchunk = $c;
286: }
287: }
288:
289: /* If we are using quick parsing of the IMAP SORT response we know the results are simply space
290: * delimited UIDs so quickly explode(). Otherwise we have to follow the spec and look for quoted
291: * strings and literals in the parse_line() routine. */
292: if ($sort) {
293: $line_cont = false;
294: $chunks = explode(' ', trim($result[$n]));
295: }
296:
297: /* properly parse the line */
298: else {
299: list($line_cont, $chunks) = $this->parse_line($result[$n], $current_size, $max, $line_length);
300: while ($this->literal_overflow) {
301: $lit_text = $this->literal_overflow;
302: $this->literal_overflow = false;
303: $current_size += mb_strlen($lit_text);
304: list($line_cont, $new_chunks) = $this->parse_line($lit_text, $current_size, $max, $line_length);
305: $chunks = array_merge($chunks, $new_chunks);
306: }
307: }
308:
309: /* merge lines that should have been recieved as one and add to results */
310: if ($chunks && !$last_line_cont) {
311: $c++;
312: }
313: if ($last_line_cont) {
314: $result[$pres] .= ' '.implode(' ', $chunks);
315: if ($chunks) {
316: $line_bits = array_merge($chunked_result[$pchunk], $chunks);
317: $chunked_result[$pchunk] = $line_bits;
318: }
319: $last_line_cont = false;
320: }
321:
322: /* add line and parsed bits to result set */
323: else {
324: $result[$n] = implode(' ', $chunks);
325: if ($chunked) {
326: $chunked_result[$c] = $chunks;
327: }
328: }
329:
330: /* check for untagged error condition. This represents a server problem but there is no reason
331: * we can't attempt to recover with the partial response we received up until this point */
332: if (mb_substr(mb_strtoupper($result[$n]), 0, 6) == '* BYE ') {
333: break;
334: }
335:
336: /* check for challenge strings */
337: if (mb_substr($result[$n], 0, 1) == '+') {
338: if (preg_match("/^\+ ([a-zA-Z0-9=]+)$/", $result[$n], $matches)) {
339: break;
340: }
341: }
342:
343: /* end outer loop when we receive the tagged response line */
344: } while (mb_substr($result[$n], 0, mb_strlen('A'.$this->command_count)) != 'A'.$this->command_count);
345:
346: /* return either raw or parsed result */
347: $this->responses[] = $result;
348: if ($chunked) {
349: $result = $chunked_result;
350: }
351: if ($this->current_command && isset($this->commands[$this->current_command])) {
352: $start_time = $this->commands[$this->current_command];
353: $this->commands[$this->current_command] = microtime(true) - $start_time;
354: if (count($this->commands) >= $this->max_history) {
355: array_shift($this->commands);
356: array_shift($this->responses);
357: }
358: }
359: return $result;
360: }
361:
362: /**
363: * put a prefix on a command and send it to the server
364: * used by scram lib, so keep it public
365: * @param mixed $command IMAP command
366: * @param bool $no_prefix flag to skip adding the prefix
367: * @return void
368: */
369: public function send_command($command, $no_prefix=false) {
370: $this->cached_response = false;
371: if (!$no_prefix) {
372: $command = 'A'.$this->command_number().' '.$command;
373: }
374:
375: /* send the command out to the server */
376: if (is_resource($this->handle)) {
377: $res = @fputs($this->handle, $command);
378: if (!$res) {
379: $this->debug[] = 'Error communicating with IMAP server: '.trim($command);
380: }
381: }
382:
383: /* save the command and time for the IMAP debug output option */
384: if (mb_strstr($command, 'LOGIN')) {
385: $command = 'LOGIN';
386: }
387: $this->commands[trim($command)] = microtime( true );
388: $this->current_command = trim($command);
389: }
390:
391: /**
392: * determine if an imap response returned an "OK", returns true or false
393: * @param array $data parsed IMAP response
394: * @param bool $chunked flag defining the type of $data
395: * @return bool true to indicate a success response from the IMAP server
396: */
397: protected function check_response($data, $chunked=false, $log_failures=true) {
398: $result = false;
399:
400: /* find the last bit of the parsed response and look for the OK atom */
401: if ($chunked) {
402: if (!empty($data) && isset($data[(count($data) - 1)])) {
403: $vals = $data[(count($data) - 1)];
404: if ($vals[0] == 'A'.$this->command_count) {
405: if (mb_strtoupper($vals[1]) == 'OK') {
406: $result = true;
407: }
408: }
409: }
410: }
411:
412: /* pattern match the last line of a raw response */
413: else {
414: $line = array_pop($data);
415: if (preg_match("/^A".$this->command_count." OK/i", $line)) {
416: $result = true;
417: }
418: }
419: if (!$result && $log_failures) {
420: $this->debug[] = 'Command FAILED: '.$this->current_command;
421: }
422: return $result;
423: }
424:
425: /**
426: * convert UTF-7 encoded forlder names to UTF-8
427: * @param string $string mailbox name to encode
428: * @return encoded mailbox
429: */
430: protected function utf7_decode($string) {
431: if ($this->utf7_folders) {
432: $string = mb_convert_encoding($string, "UTF-8", "UTF7-IMAP" );
433: }
434: return $string;
435: }
436:
437: /**
438: * convert UTF-8 encoded forlder names to UTF-7
439: * @param string $string mailbox name to decode
440: * @return decoded mailbox
441: */
442: protected function utf7_encode($string) {
443: if ($this->utf7_folders) {
444: $string = mb_convert_encoding($string, "UTF7-IMAP", "UTF-8" );
445: }
446: return $string;
447: }
448:
449: /**
450: * type checks
451: * @param string $val value to check
452: * @param string $type type of value to check against
453: * @return bool true if the type check passed
454: */
455: protected function input_validate($val, $type) {
456: $imap_search_charsets = array(
457: 'UTF-8',
458: 'US-ASCII',
459: '',
460: );
461: $imap_keywords = array(
462: 'ARRIVAL', 'DATE', 'FROM', 'SUBJECT',
463: 'CC', 'TO', 'SIZE', 'UNSEEN',
464: 'SEEN', 'FLAGGED', 'UNFLAGGED', 'ANSWERED',
465: 'UNANSWERED', 'DELETED', 'UNDELETED', 'TEXT',
466: 'ALL', 'DRAFT', 'NEW', 'RECENT', 'OLD', 'UNDRAFT',
467: 'BODY'
468: );
469: $valid = false;
470: switch ($type) {
471: case 'search_str':
472: if (preg_match("/^[^\r\n]+$/", $val)) {
473: $valid = true;
474: }
475: break;
476: case 'msg_part':
477: if (preg_match("/^[\d\.]+$/", $val)) {
478: $valid = true;
479: }
480: break;
481: case 'charset':
482: if (!$val || in_array(mb_strtoupper($val), $imap_search_charsets)) {
483: $valid = true;
484: }
485: break;
486: case 'uid':
487: if (ctype_digit((string) $val)) {
488: $valid = true;
489: }
490: break;
491: case 'uid_list';
492: if (preg_match("/^[0-9,*:]+$/", $val)) {
493: $valid = true;
494: }
495: break;
496: case 'mailbox';
497: if (preg_match("/^[^\r\n]+$/", $val)) {
498: $valid = true;
499: }
500: break;
501: case 'keyword';
502: if (in_array(mb_strtoupper($val), $imap_keywords)) {
503: $valid = true;
504: }
505: break;
506: }
507: return $valid;
508: }
509:
510: /*
511: * check for hacky stuff
512: * @param string $val value to check
513: * @param string $type type the value should match
514: * @return bool true if the value matches the type spec
515: */
516: protected function is_clean($val, $type) {
517: if (!$this->input_validate($val, $type)) {
518: $this->debug[] = 'INVALID IMAP INPUT DETECTED: '.$type.' : '.$val;
519: return false;
520: }
521: return true;
522: }
523:
524: /**
525: * overwrite defaults with supplied config array
526: * @param array $config associative array of configuration options
527: * @return void
528: */
529: protected function apply_config( $config ) {
530: foreach($config as $key => $val) {
531: if (in_array($key, $this->config)) {
532: $this->{$key} = $val;
533: }
534: }
535: }
536: }
537: