PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 10.2.2
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v10.2.2
11.1.1 11.1.0 11.0.5 11.0.4 11.0.3 11.0.2 11.0.1 11.0.0 10.11.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 trunk 10.0.0 10.0.1 10.1.0 10.1.1 10.1.2 10.1.3 10.1.4 10.2.0 10.2.1 10.2.2 All 62 releases
acymailing / back / Libraries / Imap2 / Roundcube / ImapClient.php

ImapClient.php in AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress 10.2.2, at back/Libraries/Imap2/Roundcube/ImapClient.php

4,268 lines 133.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * +-----------------------------------------------------------------------+
5 * | This file is part of the Roundcube Webmail client |
6 * | |
7 * | Copyright (C) The Roundcube Dev Team |
8 * | Copyright (C) Kolab Systems AG |
9 * | |
10 * | Licensed under the GNU General Public License version 3 or |
11 * | any later version with exceptions for skins & plugins. |
12 * | See the README file for a full license statement. |
13 * | |
14 * | PURPOSE: |
15 * | Provide alternative IMAP library that doesn't rely on the standard |
16 * | C-Client based version. This allows to function regardless |
17 * | of whether or not the PHP build it's running on has IMAP |
18 * | functionality built-in. |
19 * | |
20 * | Based on Iloha IMAP Library. See http://ilohamail.org/ for details |
21 * +-----------------------------------------------------------------------+
22 * | Author: Aleksander Machniak <[email protected]> |
23 * | Author: Ryo Chijiiwa <[email protected]> |
24 * +-----------------------------------------------------------------------+
25 */
26
27 namespace AcyMailing\Libraries\Imap2\Roundcube;
28
29 use AcyMailing\Libraries\Imap2\Auth_SASL;
30 use AcyMailing\Libraries\Imap2\GSSAPIContext;
31 use AcyMailing\Libraries\Imap2\KRB5CCache;
32 use AcyMailing\Libraries\Imap2\rcube_message_header;
33 use AcyMailing\Libraries\Imap2\rcube_result_index;
34 use AcyMailing\Libraries\Imap2\rcube_result_thread;
35
36 /**
37 * PHP based wrapper class to connect to an IMAP server
38 *
39 * @package Framework
40 * @subpackage Storage
41 */
42 class ImapClient
43 {
44 public $error;
45 public $errornum;
46 public $result;
47 public $resultcode;
48 public $selected;
49 public $data = [];
50 public $flags = [
51 'SEEN' => '\\Seen',
52 'DELETED' => '\\Deleted',
53 'ANSWERED' => '\\Answered',
54 'DRAFT' => '\\Draft',
55 'FLAGGED' => '\\Flagged',
56 'FORWARDED' => '$Forwarded',
57 'MDNSENT' => '$MDNSent',
58 '*' => '\\*',
59 ];
60
61 protected $fp;
62 protected $host;
63 protected $cmd_tag;
64 protected $cmd_num = 0;
65 protected $resourceid;
66 protected $prefs = [];
67 protected $logged = false;
68 protected $capability = [];
69 protected $capability_readed = false;
70 protected $debug = false;
71 protected $debug_handler = false;
72 protected $rawLastLine;
73
74 const ERROR_OK = 0;
75 const ERROR_NO = -1;
76 const ERROR_BAD = -2;
77 const ERROR_BYE = -3;
78 const ERROR_UNKNOWN = -4;
79 const ERROR_COMMAND = -5;
80 const ERROR_READONLY = -6;
81
82 const COMMAND_NORESPONSE = 1;
83 const COMMAND_CAPABILITY = 2;
84 const COMMAND_LASTLINE = 4;
85 const COMMAND_ANONYMIZED = 8;
86 const COMMAND_RAW_LASTLINE = 16;
87
88 const DEBUG_LINE_LENGTH = 4098; // 4KB + 2B for \r\n
89
90
91 /**
92 * Send simple (one line) command to the connection stream
93 *
94 * @param string $string Command string
95 * @param bool $endln True if CRLF need to be added at the end of command
96 * @param bool $anonymized Don't write the given data to log but a placeholder
97 *
98 * @param int Number of bytes sent, False on error
99 */
100 protected function putLine($string, $endln = true, $anonymized = false)
101 {
102 if (!$this->fp) {
103 return false;
104 }
105
106 if ($this->debug) {
107 // anonymize the sent command for logging
108 $cut = $endln ? 2 : 0;
109 if ($anonymized && preg_match('/^(A\d+ (?:[A-Z]+ )+)(.+)/', $string, $m)) {
110 $log = $m[1].sprintf('****** [%d]', strlen($m[2]) - $cut);
111 } elseif ($anonymized) {
112 $log = sprintf('****** [%d]', strlen($string) - $cut);
113 } else {
114 $log = rtrim($string);
115 }
116
117 $this->debug('C: '.$log);
118 }
119
120 if ($endln) {
121 $string .= "\r\n";
122 }
123
124 $res = fwrite($this->fp, $string);
125
126 if ($res === false) {
127 $this->closeSocket();
128 }
129
130 return $res;
131 }
132
133 /**
134 * Send command to the connection stream with Command Continuation
135 * Requests (RFC3501 7.5) and LITERAL+ (RFC2088) support
136 *
137 * @param string $string Command string
138 * @param bool $endln True if CRLF need to be added at the end of command
139 * @param bool $anonymized Don't write the given data to log but a placeholder
140 *
141 * @return int|bool Number of bytes sent, False on error
142 */
143 protected function putLineC($string, $endln = true, $anonymized = false)
144 {
145 if (!$this->fp) {
146 return false;
147 }
148
149 if ($endln) {
150 $string .= "\r\n";
151 }
152
153 $res = 0;
154 if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) {
155 for ($i = 0, $cnt = count($parts) ; $i < $cnt ; $i++) {
156 if (isset($parts[$i + 1]) && preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i + 1], $matches)) {
157 // LITERAL+ support
158 if ($this->prefs['literal+']) {
159 $parts[$i + 1] = sprintf("{%d+}\r\n", $matches[1]);
160 }
161
162 $bytes = $this->putLine($parts[$i].$parts[$i + 1], false, $anonymized);
163 if ($bytes === false) {
164 return false;
165 }
166
167 $res += $bytes;
168
169 // don't wait if server supports LITERAL+ capability
170 if (!$this->prefs['literal+']) {
171 $line = $this->readLine(1000);
172 // handle error in command
173 if ($line[0] != '+') {
174 return false;
175 }
176 }
177
178 $i++;
179 } else {
180 $bytes = $this->putLine($parts[$i], false, $anonymized);
181 if ($bytes === false) {
182 return false;
183 }
184
185 $res += $bytes;
186 }
187 }
188 }
189
190 return $res;
191 }
192
193 /**
194 * Reads line from the connection stream
195 *
196 * @param int $size Buffer size
197 *
198 * @return string Line of text response
199 */
200 protected function readLine($size = 1024)
201 {
202 $line = '';
203
204 if (!$size) {
205 $size = 1024;
206 }
207
208 do {
209 if ($this->eof()) {
210 return $line ? : null;
211 }
212
213 $buffer = fgets($this->fp, $size);
214
215 if ($buffer === false) {
216 $this->closeSocket();
217 break;
218 }
219
220 if ($this->debug) {
221 $this->debug('S: '.rtrim($buffer));
222 }
223
224 $line .= $buffer;
225 } while (substr($buffer, -1) != "\n");
226
227 return $line;
228 }
229
230 /**
231 * Reads a line of data from the connection stream inluding all
232 * string continuation literals.
233 *
234 * @param int $size Buffer size
235 *
236 * @return string Line of text response
237 */
238 protected function readFullLine($size = 1024)
239 {
240 $line = $this->readLine($size);
241
242 // include all string literels untile the real end of "line"
243 while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) {
244 $bytes = $m[1];
245 $out = '';
246
247 while (strlen($out) < $bytes) {
248 $out = $this->readBytes($bytes);
249 if ($out === null) {
250 break;
251 }
252
253 $line .= $out;
254 }
255
256 $line .= $this->readLine($size);
257 }
258
259 return $line;
260 }
261
262 /**
263 * Reads more data from the connection stream when provided
264 * data contain string literal
265 *
266 * @param string $line Response text
267 * @param bool $escape Enables escaping
268 *
269 * @return string Line of text response
270 */
271 protected function multLine($line, $escape = false)
272 {
273 $line = rtrim($line);
274 if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
275 $out = '';
276 $str = substr($line, 0, -strlen($m[0]));
277 $bytes = $m[1];
278
279 while (strlen($out) < $bytes) {
280 $line = $this->readBytes($bytes);
281 if ($line === null) {
282 break;
283 }
284
285 $out .= $line;
286 }
287
288 $line = $str.($escape ? $this->escape($out) : $out);
289 }
290
291 return $line;
292 }
293
294 /**
295 * Reads specified number of bytes from the connection stream
296 *
297 * @param int $bytes Number of bytes to get
298 *
299 * @return string Response text
300 */
301 protected function readBytes($bytes)
302 {
303 $data = '';
304 $len = 0;
305
306 while ($len < $bytes && !$this->eof()) {
307 $d = fread($this->fp, $bytes - $len);
308 if ($this->debug) {
309 $this->debug('S: '.$d);
310 }
311 $data .= $d;
312 $data_len = strlen($data);
313 if ($len == $data_len) {
314 break; // nothing was read -> exit to avoid apache lockups
315 }
316 $len = $data_len;
317 }
318
319 return $data;
320 }
321
322 /**
323 * Reads complete response to the IMAP command
324 *
325 * @param array $untagged Will be filled with untagged response lines
326 *
327 * @return string Response text
328 */
329 protected function readReply(&$untagged = null)
330 {
331 do {
332 $line = trim($this->readLine(1024));
333 // store untagged response lines
334 if (!empty($line) && $line[0] == '*') {
335 $untagged[] = $line;
336 }
337 } while (!empty($line) && $line[0] == '*');
338
339 if ($untagged) {
340 $untagged = implode("\n", $untagged);
341 }
342
343 return $line;
344 }
345
346 /**
347 * Response parser.
348 *
349 * @param string $string Response text
350 * @param string $err_prefix Error message prefix
351 *
352 * @return int Response status
353 */
354 protected function parseResult($string, $err_prefix = '')
355 {
356 if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) {
357 $res = strtoupper($matches[1]);
358 $str = trim($matches[2]);
359
360 if ($res == 'OK') {
361 $this->errornum = self::ERROR_OK;
362 } elseif ($res == 'NO') {
363 $this->errornum = self::ERROR_NO;
364 } elseif ($res == 'BAD') {
365 $this->errornum = self::ERROR_BAD;
366 } elseif ($res == 'BYE') {
367 $this->closeSocket();
368 $this->errornum = self::ERROR_BYE;
369 }
370
371 if ($str) {
372 $str = trim($str);
373 // get response string and code (RFC5530)
374 if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) {
375 $this->resultcode = strtoupper($m[1]);
376 $str = trim(substr($str, strlen($m[1]) + 2));
377 } else {
378 $this->resultcode = null;
379 // parse response for [APPENDUID 1204196876 3456]
380 if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) {
381 $this->data['APPENDUID'] = $m[1];
382 } // parse response for [COPYUID 1204196876 3456:3457 123:124]
383 elseif (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) {
384 $this->data['COPYUID'] = [$m[1], $m[2]];
385 }
386 }
387
388 $this->result = $str;
389
390 if ($this->errornum != self::ERROR_OK) {
391 $this->error = $err_prefix ? $err_prefix.$str : $str;
392 }
393 }
394
395 return $this->errornum;
396 }
397
398 return self::ERROR_UNKNOWN;
399 }
400
401 /**
402 * Checks connection stream state.
403 *
404 * @return bool True if connection is closed
405 */
406 protected function eof()
407 {
408 if (!is_resource($this->fp)) {
409 return true;
410 }
411
412 // If a connection opened by fsockopen() wasn't closed
413 // by the server, feof() will hang.
414 $start = microtime(true);
415
416 if (feof($this->fp) ||
417 ($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout']))
418 ) {
419 $this->closeSocket();
420
421 return true;
422 }
423
424 return false;
425 }
426
427 /**
428 * Closes connection stream.
429 */
430 protected function closeSocket()
431 {
432 if (!empty($this->fp)) {
433 @fclose($this->fp);
434 }
435 $this->fp = null;
436 }
437
438 /**
439 * Error code/message setter.
440 */
441 public function setError($code, $msg = '')
442 {
443 $this->errornum = $code;
444 $this->error = $msg;
445
446 return $code;
447 }
448
449 /**
450 * Checks response status.
451 * Checks if command response line starts with specified prefix (or * BYE/BAD)
452 *
453 * @param string $string Response text
454 * @param string $match Prefix to match with (case-sensitive)
455 * @param bool $error Enables BYE/BAD checking
456 * @param bool $nonempty Enables empty response checking
457 *
458 * @return bool True any check is true or connection is closed.
459 */
460 protected function startsWith($string, $match, $error = false, $nonempty = false)
461 {
462 if (!$this->fp) {
463 return true;
464 }
465
466 if (strncmp($string, $match, strlen($match)) == 0) {
467 return true;
468 }
469
470 if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) {
471 if (strtoupper($m[1]) == 'BYE') {
472 $this->closeSocket();
473 }
474
475 return true;
476 }
477
478 if ($nonempty && !strlen($string)) {
479 return true;
480 }
481
482 return false;
483 }
484
485 /**
486 * Capabilities checker
487 */
488 protected function hasCapability($name)
489 {
490 if (empty($this->capability) || $name == '') {
491 return false;
492 }
493
494 if (in_array($name, $this->capability)) {
495 return true;
496 } elseif (strpos($name, '=')) {
497 return false;
498 }
499
500 $result = [];
501 foreach ($this->capability as $cap) {
502 $entry = explode('=', $cap);
503 if ($entry[0] == $name) {
504 $result[] = $entry[1];
505 }
506 }
507
508 return $result ? : false;
509 }
510
511 /**
512 * Capabilities checker
513 *
514 * @param string $name Capability name
515 *
516 * @return mixed Capability values array for key=value pairs, true/false for others
517 */
518 public function getCapability($name)
519 {
520 $result = $this->hasCapability($name);
521
522 if (!empty($result)) {
523 return $result;
524 } elseif ($this->capability_readed) {
525 return false;
526 }
527
528 // get capabilities (only once) because initial
529 // optional CAPABILITY response may differ
530 $result = $this->execute('CAPABILITY');
531 if ($result[0] == self::ERROR_OK) {
532 $this->parseCapability($result[1]);
533 }
534
535 $this->capability_readed = true;
536
537 return $this->hasCapability($name);
538 }
539
540 /**
541 * Clears detected server capabilities
542 */
543 public function clearCapability()
544 {
545 $this->capability = [];
546 $this->capability_readed = false;
547 }
548
549 /**
550 * DIGEST-MD5/CRAM-MD5/PLAIN Authentication
551 *
552 * @param string $user Username
553 * @param string $pass Password
554 * @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5)
555 *
556 * @return resource Connection resourse on success, error code on error
557 */
558 protected function authenticate($user, $pass, $type = 'PLAIN')
559 {
560 if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') {
561 if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) {
562 return $this->setError(
563 self::ERROR_BYE,
564 "The Auth_SASL package is required for DIGEST-MD5 authentication"
565 );
566 }
567
568 $this->putLine($this->nextTag()." AUTHENTICATE $type");
569 $line = trim($this->readReply());
570
571 if ($line[0] == '+') {
572 $challenge = substr($line, 2);
573 } else {
574 return $this->parseResult($line);
575 }
576
577 if ($type == 'CRAM-MD5') {
578 // RFC2195: CRAM-MD5
579 $ipad = '';
580 $opad = '';
581 $xor = function ($str1, $str2) {
582 $result = '';
583 $size = strlen($str1);
584 for ($i = 0 ; $i < $size ; $i++) {
585 $result .= chr(ord($str1[$i]) ^ ord($str2[$i]));
586 }
587
588 return $result;
589 };
590
591 // initialize ipad, opad
592 for ($i = 0 ; $i < 64 ; $i++) {
593 $ipad .= chr(0x36);
594 $opad .= chr(0x5C);
595 }
596
597 // pad $pass so it's 64 bytes
598 $pass = str_pad($pass, 64, chr(0));
599
600 // generate hash
601 $hash = md5(
602 $xor($pass, $opad).pack(
603 "H*",
604 md5($xor($pass, $ipad).base64_decode($challenge))
605 )
606 );
607 $reply = base64_encode($user.' '.$hash);
608
609 // send result
610 $this->putLine($reply, true, true);
611 } else {
612 // RFC2831: DIGEST-MD5
613 // proxy authorization
614 if (!empty($this->prefs['auth_cid'])) {
615 $authc = $this->prefs['auth_cid'];
616 $pass = $this->prefs['auth_pw'];
617 } else {
618 $authc = $user;
619 $user = '';
620 }
621
622 $auth_sasl = new Auth_SASL;
623 $auth_sasl = $auth_sasl->factory('digestmd5');
624 $reply = base64_encode(
625 $auth_sasl->getResponse(
626 $authc,
627 $pass,
628 base64_decode($challenge),
629 $this->host,
630 'imap',
631 $user
632 )
633 );
634
635 // send result
636 $this->putLine($reply, true, true);
637 $line = trim($this->readReply());
638
639 if ($line[0] != '+') {
640 return $this->parseResult($line);
641 }
642
643 // check response
644 $challenge = substr($line, 2);
645 $challenge = base64_decode($challenge);
646 if (strpos($challenge, 'rspauth=') === false) {
647 return $this->setError(
648 self::ERROR_BAD,
649 "Unexpected response from server to DIGEST-MD5 response"
650 );
651 }
652
653 $this->putLine('');
654 }
655
656 $line = $this->readReply();
657 $result = $this->parseResult($line);
658 } elseif ($type == 'GSSAPI') {
659 if (!extension_loaded('krb5')) {
660 return $this->setError(
661 self::ERROR_BYE,
662 "The krb5 extension is required for GSSAPI authentication"
663 );
664 }
665
666 if (empty($this->prefs['gssapi_cn'])) {
667 return $this->setError(
668 self::ERROR_BYE,
669 "The gssapi_cn parameter is required for GSSAPI authentication"
670 );
671 }
672
673 if (empty($this->prefs['gssapi_context'])) {
674 return $this->setError(
675 self::ERROR_BYE,
676 "The gssapi_context parameter is required for GSSAPI authentication"
677 );
678 }
679
680 putenv('KRB5CCNAME='.$this->prefs['gssapi_cn']);
681
682 try {
683 $ccache = new KRB5CCache();
684 $ccache->open($this->prefs['gssapi_cn']);
685 $gssapicontext = new GSSAPIContext();
686 $gssapicontext->acquireCredentials($ccache);
687
688 $token = '';
689 $success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], null, null, null, $token);
690 $token = base64_encode($token);
691 } catch (\Exception $e) {
692 trigger_error($e->getMessage(), E_USER_WARNING);
693
694 return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
695 }
696
697 $this->putLine($this->nextTag()." AUTHENTICATE GSSAPI ".$token);
698 $line = trim($this->readReply());
699
700 if ($line[0] != '+') {
701 return $this->parseResult($line);
702 }
703
704 try {
705 $itoken = base64_decode(substr($line, 2));
706
707 if (!$gssapicontext->unwrap($itoken, $itoken)) {
708 throw new \Exception("GSSAPI SASL input token unwrap failed");
709 }
710
711 if (strlen($itoken) < 4) {
712 throw new \Exception("GSSAPI SASL input token invalid");
713 }
714
715 // Integrity/encryption layers are not supported. The first bit
716 // indicates that the server supports "no security layers".
717 // 0x00 should not occur, but support broken implementations.
718 $server_layers = ord($itoken[0]);
719 if ($server_layers && ($server_layers & 0x1) != 0x1) {
720 throw new \Exception("Server requires GSSAPI SASL integrity/encryption");
721 }
722
723 // Construct output token. 0x01 in the first octet = SASL layer "none",
724 // zero in the following three octets = no data follows.
725 // See https://github.com/cyrusimap/cyrus-sasl/blob/e41cfb986c1b1935770de554872247453fdbb079/plugins/gssapi.c#L1284
726 if (!$gssapicontext->wrap(pack("CCCC", 0x1, 0, 0, 0), $otoken, true)) {
727 throw new \Exception("GSSAPI SASL output token wrap failed");
728 }
729 } catch (\Exception $e) {
730 trigger_error($e->getMessage(), E_USER_WARNING);
731
732 return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
733 }
734
735 $this->putLine(base64_encode($otoken));
736
737 $line = $this->readReply();
738 $result = $this->parseResult($line);
739 } elseif ($type == 'PLAIN') {
740 // proxy authorization
741 if (!empty($this->prefs['auth_cid'])) {
742 $authc = $this->prefs['auth_cid'];
743 $pass = $this->prefs['auth_pw'];
744 } else {
745 $authc = $user;
746 $user = '';
747 }
748
749 $reply = base64_encode($user.chr(0).$authc.chr(0).$pass);
750
751 // RFC 4959 (SASL-IR): save one round trip
752 if ($this->getCapability('SASL-IR')) {
753 [$result, $line] = $this->execute(
754 "AUTHENTICATE PLAIN",
755 [$reply],
756 self::COMMAND_RAW_LASTLINE | self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED
757 );
758 } else {
759 $this->putLine($this->nextTag()." AUTHENTICATE PLAIN");
760 $line = trim($this->readReply());
761
762 if ($line[0] != '+') {
763 return $this->parseResult($line);
764 }
765
766 // send result, get reply and process it
767 $this->putLine($reply, true, true);
768 $line = $this->readReply();
769 $result = $this->parseResult($line);
770 }
771 } elseif ($type == 'LOGIN') {
772 $this->putLine($this->nextTag()." AUTHENTICATE LOGIN");
773
774 $line = trim($this->readReply());
775 if ($line[0] != '+') {
776 return $this->parseResult($line);
777 }
778
779 $this->putLine(base64_encode($user), true, true);
780
781 $line = trim($this->readReply());
782 if ($line[0] != '+') {
783 return $this->parseResult($line);
784 }
785
786 // send result, get reply and process it
787 $this->putLine(base64_encode($pass), true, true);
788
789 $line = $this->readReply();
790 $result = $this->parseResult($line);
791 } elseif ($type == 'XOAUTH2') {
792 $auth = base64_encode("user=$user\1auth=Bearer $pass\1\1");
793 $this->putLine($this->nextTag()." AUTHENTICATE XOAUTH2 $auth", true, true);
794
795 $line = trim($this->readReply());
796 if (!empty($line) && $line[0] == '+') {
797 // send empty line
798 $this->putLine('', true, true);
799 $line = $this->readReply();
800 }
801
802 $result = $this->parseResult($line);
803 }
804
805 if ($result === self::ERROR_OK) {
806 // optional CAPABILITY response
807 if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
808 $this->parseCapability($matches[1], true);
809 }
810
811 return $this->fp;
812 }
813
814 return $this->setError($result, "AUTHENTICATE $type: $line");
815 }
816
817 /**
818 * LOGIN Authentication
819 *
820 * @param string $user Username
821 * @param string $pass Password
822 *
823 * @return resource Connection resourse on success, error code on error
824 */
825 protected function login($user, $password)
826 {
827 // Prevent from sending credentials in plain text when connection is not secure
828 if ($this->getCapability('LOGINDISABLED')) {
829 return $this->setError(self::ERROR_BAD, "Login disabled by IMAP server");
830 }
831
832 [$code, $response] = $this->execute('LOGIN', [
833 $this->escape($user),
834 $this->escape($password),
835 ], self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
836
837 // re-set capabilities list if untagged CAPABILITY response provided
838 if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) {
839 $this->parseCapability($matches[1], true);
840 }
841
842 if ($code == self::ERROR_OK) {
843 return $this->fp;
844 }
845
846 return $code;
847 }
848
849 /**
850 * Detects hierarchy delimiter
851 *
852 * @return string The delimiter
853 */
854 public function getHierarchyDelimiter()
855 {
856 if (isset($this->prefs['delimiter']) && $this->prefs['delimiter']) {
857 return $this->prefs['delimiter'];
858 }
859
860 // try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8)
861 [$code, $response] = $this->execute(
862 'LIST',
863 [$this->escape(''), $this->escape('')]
864 );
865
866 if ($code == self::ERROR_OK) {
867 $args = $this->tokenizeResponse($response, 4);
868 $delimiter = $args[3];
869
870 if (strlen($delimiter) > 0) {
871 return ($this->prefs['delimiter'] = $delimiter);
872 }
873 }
874 }
875
876 /**
877 * NAMESPACE handler (RFC 2342)
878 *
879 * @return array Namespace data hash (personal, other, shared)
880 */
881 public function getNamespace()
882 {
883 if (array_key_exists('namespace', $this->prefs)) {
884 return $this->prefs['namespace'];
885 }
886
887 if (!$this->getCapability('NAMESPACE')) {
888 return self::ERROR_BAD;
889 }
890
891 [$code, $response] = $this->execute('NAMESPACE');
892
893 if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) {
894 $response = substr($response, 11);
895 $data = $this->tokenizeResponse($response);
896 }
897
898 if (!is_array($data)) {
899 return $code;
900 }
901
902 $this->prefs['namespace'] = [
903 'personal' => $data[0],
904 'other' => $data[1],
905 'shared' => $data[2],
906 ];
907
908 return $this->prefs['namespace'];
909 }
910
911 /**
912 * Connects to IMAP server and authenticates.
913 *
914 * @param string $host Server hostname or IP
915 * @param string $user User name
916 * @param string $password Password
917 * @param array $options Connection and class options
918 *
919 * @return bool True on success, False on failure
920 */
921 public function connect($host, $user, $password, $options = [])
922 {
923 // configure
924 $this->set_prefs($options);
925
926 $this->host = $host;
927 $this->user = $user;
928 $this->logged = false;
929 $this->selected = null;
930
931 // check input
932 if (empty($host)) {
933 $this->setError(self::ERROR_BAD, "Empty host");
934
935 return false;
936 }
937
938 if (empty($user)) {
939 $this->setError(self::ERROR_NO, "Empty user");
940
941 return false;
942 }
943
944 if (empty($password) && empty($options['gssapi_cn'])) {
945 $this->setError(self::ERROR_NO, "Empty password");
946
947 return false;
948 }
949
950 // Connect
951 if (!$this->_connect($host)) {
952 return false;
953 }
954
955 // Send ID info
956 if (!empty($this->prefs['ident']) && $this->getCapability('ID')) {
957 $this->data['ID'] = $this->id($this->prefs['ident']);
958 }
959
960 $auth_method = $this->prefs['auth_type'];
961
962 // Switch to XOAUTH2 if password is a JWT token
963 $token = json_decode(base64_decode(str_replace('_', '/', str_replace('-', '+', @explode('.', $password)[0]))), true);
964 if (is_array($token) && isset($token['typ']) && $token['typ'] == 'JWT') {
965 $auth_method = 'XOAUTH2';
966 }
967
968 $auth_methods = [];
969 $result = null;
970
971 // check for supported auth methods
972 if (!$auth_method || $auth_method == 'CHECK') {
973 if ($auth_caps = $this->getCapability('AUTH')) {
974 $auth_methods = $auth_caps;
975 }
976
977 // Use best (for security) supported authentication method
978 $all_methods = ['DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN', 'XOAUTH2'];
979
980 if (!empty($this->prefs['gssapi_cn'])) {
981 array_unshift($all_methods, 'GSSAPI');
982 }
983
984 foreach ($all_methods as $auth_method) {
985 if (in_array($auth_method, $auth_methods)) {
986 break;
987 }
988 }
989
990 // Prefer LOGIN over AUTHENTICATE LOGIN for performance reasons
991 if ($auth_method == 'LOGIN' && !$this->getCapability('LOGINDISABLED')) {
992 $auth_method = 'IMAP';
993 }
994 }
995
996 // pre-login capabilities can be not complete
997 $this->capability_readed = false;
998
999
1000 // Authenticate
1001 switch ($auth_method) {
1002 case 'CRAM_MD5':
1003 $auth_method = 'CRAM-MD5';
1004 case 'CRAM-MD5':
1005 case 'DIGEST-MD5':
1006 case 'GSSAPI':
1007 case 'PLAIN':
1008 case 'LOGIN':
1009 case 'XOAUTH2':
1010 $result = $this->authenticate($user, $password, $auth_method);
1011 break;
1012
1013 case 'IMAP':
1014 $result = $this->login($user, $password);
1015 break;
1016
1017 default:
1018 $this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method");
1019 }
1020
1021 // Connected and authenticated
1022 if (is_resource($result)) {
1023 if ($this->prefs['force_caps']) {
1024 $this->clearCapability();
1025 }
1026 $this->logged = true;
1027
1028 return true;
1029 }
1030
1031 $this->closeConnection();
1032
1033 return false;
1034 }
1035
1036 /**
1037 * Connects to IMAP server.
1038 *
1039 * @param string $host Server hostname or IP
1040 *
1041 * @return bool True on success, False on failure
1042 */
1043 protected function _connect($host)
1044 {
1045 // initialize connection
1046 $this->error = '';
1047 $this->errornum = self::ERROR_OK;
1048
1049 if (!$this->prefs['port']) {
1050 $this->prefs['port'] = 143;
1051 }
1052
1053 // check for SSL
1054 if ($this->prefs['ssl_mode'] && $this->prefs['ssl_mode'] != 'tls') {
1055 $host = $this->prefs['ssl_mode'].'://'.$host;
1056 }
1057
1058 if ($this->prefs['timeout'] <= 0) {
1059 $this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout')));
1060 }
1061
1062 if ($this->debug) {
1063 // set connection identifier for debug output
1064 $this->resourceid = strtoupper(substr(md5(microtime().$host.$this->user), 0, 4));
1065
1066 $_host = ($this->prefs['ssl_mode'] == 'tls' ? 'tls://' : '').$host.':'.$this->prefs['port'];
1067 $this->debug("Connecting to $_host...");
1068 }
1069
1070 if (!empty($this->prefs['socket_options'])) {
1071 $context = stream_context_create($this->prefs['socket_options']);
1072 $this->fp = stream_socket_client(
1073 $host.':'.$this->prefs['port'],
1074 $errno,
1075 $errstr,
1076 $this->prefs['timeout'],
1077 STREAM_CLIENT_CONNECT,
1078 $context
1079 );
1080 } else {
1081 $this->fp = @fsockopen($host, $this->prefs['port'], $errno, $errstr, $this->prefs['timeout']);
1082 }
1083
1084 if (!$this->fp) {
1085 $this->setError(
1086 self::ERROR_BAD,
1087 sprintf(
1088 "Could not connect to %s:%d: %s",
1089 $host,
1090 $this->prefs['port'],
1091 $errstr ? : "Unknown reason"
1092 )
1093 );
1094
1095 return false;
1096 }
1097
1098 if ($this->prefs['timeout'] > 0) {
1099 stream_set_timeout($this->fp, $this->prefs['timeout']);
1100 }
1101
1102 $line = trim(fgets($this->fp, 8192));
1103
1104 if ($this->debug && $line) {
1105 $this->debug('S: '.$line);
1106 }
1107
1108 // Connected to wrong port or connection error?
1109 if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) {
1110 if ($line) {
1111 $error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $this->prefs['port'], $line);
1112 } else {
1113 $error = sprintf("Empty startup greeting (%s:%d)", $host, $this->prefs['port']);
1114 }
1115
1116 $this->setError(self::ERROR_BAD, $error);
1117 $this->closeConnection();
1118
1119 return false;
1120 }
1121
1122 $this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line));
1123
1124 // RFC3501 [7.1] optional CAPABILITY response
1125 if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
1126 $this->parseCapability($matches[1], true);
1127 }
1128
1129 // TLS connection
1130 if ($this->prefs['ssl_mode'] == 'tls' && $this->getCapability('STARTTLS')) {
1131 $res = $this->execute('STARTTLS');
1132
1133 if ($res[0] != self::ERROR_OK) {
1134 $this->closeConnection();
1135
1136 return false;
1137 }
1138
1139 if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) {
1140 $crypto_method = $this->prefs['socket_options']['ssl']['crypto_method'];
1141 } else {
1142 // There is no flag to enable all TLS methods. Net_SMTP
1143 // handles enabling TLS similarly.
1144 $crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT
1145 | @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
1146 | @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
1147 }
1148
1149 if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) {
1150 $this->setError(self::ERROR_BAD, "Unable to negotiate TLS");
1151 $this->closeConnection();
1152
1153 return false;
1154 }
1155
1156 // Now we're secure, capabilities need to be reread
1157 $this->clearCapability();
1158 }
1159
1160 return true;
1161 }
1162
1163 /**
1164 * Initializes environment
1165 */
1166 protected function set_prefs($prefs)
1167 {
1168 // set preferences
1169 if (is_array($prefs)) {
1170 $this->prefs = $prefs;
1171 }
1172
1173 // set auth method
1174 if (!empty($this->prefs['auth_type'])) {
1175 $this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']);
1176 } else {
1177 $this->prefs['auth_type'] = 'CHECK';
1178 }
1179
1180 // disabled capabilities
1181 if (!empty($this->prefs['disabled_caps'])) {
1182 $this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']);
1183 }
1184
1185 // additional message flags
1186 if (!empty($this->prefs['message_flags'])) {
1187 $this->flags = array_merge($this->flags, $this->prefs['message_flags']);
1188 unset($this->prefs['message_flags']);
1189 }
1190 }
1191
1192 /**
1193 * Checks connection status
1194 *
1195 * @return bool True if connection is active and user is logged in, False otherwise.
1196 */
1197 public function connected()
1198 {
1199 return $this->fp && $this->logged;
1200 }
1201
1202 /**
1203 * Closes connection with logout.
1204 */
1205 public function closeConnection()
1206 {
1207 if ($this->logged && $this->putLine($this->nextTag().' LOGOUT')) {
1208 $this->readReply();
1209 }
1210
1211 $this->closeSocket();
1212 }
1213
1214 /**
1215 * Executes SELECT command (if mailbox is already not in selected state)
1216 *
1217 * @param string $mailbox Mailbox name
1218 * @param array $qresync_data QRESYNC data (RFC5162)
1219 *
1220 * @return boolean True on success, false on error
1221 */
1222 public function select($mailbox, $qresync_data = null)
1223 {
1224 if (!strlen($mailbox)) {
1225 return false;
1226 }
1227
1228 if ($this->selected === $mailbox) {
1229 return true;
1230 }
1231
1232 $params = [$this->escape($mailbox)];
1233
1234 // QRESYNC data items
1235 // 0. the last known UIDVALIDITY,
1236 // 1. the last known modification sequence,
1237 // 2. the optional set of known UIDs, and
1238 // 3. an optional parenthesized list of known sequence ranges and their
1239 // corresponding UIDs.
1240 if (!empty($qresync_data)) {
1241 if (!empty($qresync_data[2])) {
1242 $qresync_data[2] = self::compressMessageSet($qresync_data[2]);
1243 }
1244
1245 $params[] = ['QRESYNC', $qresync_data];
1246 }
1247
1248 [$code, $response] = $this->execute('SELECT', $params);
1249
1250 if ($code == self::ERROR_OK) {
1251 $this->clear_mailbox_cache();
1252
1253 $response = explode("\r\n", $response);
1254 foreach ($response as $line) {
1255 if (preg_match('/^\* OK \[/i', $line)) {
1256 $pos = strcspn($line, ' ]', 6);
1257 $token = strtoupper(substr($line, 6, $pos));
1258 $pos += 7;
1259
1260 switch ($token) {
1261 case 'UIDNEXT':
1262 case 'UIDVALIDITY':
1263 case 'UNSEEN':
1264 if ($len = strspn($line, '0123456789', $pos)) {
1265 $this->data[$token] = (int)substr($line, $pos, $len);
1266 }
1267 break;
1268
1269 case 'HIGHESTMODSEQ':
1270 if ($len = strspn($line, '0123456789', $pos)) {
1271 $this->data[$token] = (string)substr($line, $pos, $len);
1272 }
1273 break;
1274
1275 case 'NOMODSEQ':
1276 $this->data[$token] = true;
1277 break;
1278
1279 case 'PERMANENTFLAGS':
1280 $start = strpos($line, '(', $pos);
1281 $end = strrpos($line, ')');
1282 if ($start && $end) {
1283 $flags = substr($line, $start + 1, $end - $start - 1);
1284 $this->data[$token] = explode(' ', $flags);
1285 }
1286 break;
1287 }
1288 } elseif (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) {
1289 $token = strtoupper($match[2]);
1290 switch ($token) {
1291 case 'EXISTS':
1292 case 'RECENT':
1293 $this->data[$token] = (int)$match[1];
1294 break;
1295
1296 case 'FETCH':
1297 // QRESYNC FETCH response (RFC5162)
1298 $line = substr($line, strlen($match[0]));
1299 $fetch_data = $this->tokenizeResponse($line, 1);
1300 $data = ['id' => $match[1]];
1301
1302 for ($i = 0, $size = count($fetch_data) ; $i < $size ; $i += 2) {
1303 $data[strtolower($fetch_data[$i])] = $fetch_data[$i + 1];
1304 }
1305
1306 $this->data['QRESYNC'][$data['uid']] = $data;
1307 break;
1308 }
1309 } // QRESYNC VANISHED response (RFC5162)
1310 elseif (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
1311 $line = substr($line, strlen($match[0]));
1312 $v_data = $this->tokenizeResponse($line, 1);
1313
1314 $this->data['VANISHED'] = $v_data;
1315 }
1316 }
1317
1318 $this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY';
1319 $this->selected = $mailbox;
1320
1321 return true;
1322 }
1323
1324 return false;
1325 }
1326
1327 /**
1328 * Executes STATUS command
1329 *
1330 * @param string $mailbox Mailbox name
1331 * @param array $items Additional requested item names. By default
1332 * MESSAGES and UNSEEN are requested. Other defined
1333 * in RFC3501: UIDNEXT, UIDVALIDITY, RECENT
1334 *
1335 * @return array Status item-value hash
1336 * @since 0.5-beta
1337 */
1338 public function status($mailbox, $items = [])
1339 {
1340 if (!strlen($mailbox)) {
1341 return false;
1342 }
1343
1344 if (!in_array('MESSAGES', $items)) {
1345 $items[] = 'MESSAGES';
1346 }
1347 if (!in_array('UNSEEN', $items)) {
1348 $items[] = 'UNSEEN';
1349 }
1350
1351 [$code, $response] = $this->execute(
1352 'STATUS',
1353 [$this->escape($mailbox), '('.implode(' ', $items).')'],
1354 0,
1355 '/^\* STATUS /i'
1356 );
1357
1358 if ($code == self::ERROR_OK && $response) {
1359 $result = [];
1360 $response = substr($response, 9); // remove prefix "* STATUS "
1361
1362 [$mbox, $items] = $this->tokenizeResponse($response, 2);
1363
1364 // Fix for #1487859. Some buggy server returns not quoted
1365 // folder name with spaces. Let's try to handle this situation
1366 if (!is_array($items) && ($pos = strpos($response, '(')) !== false) {
1367 $response = substr($response, $pos);
1368 $items = $this->tokenizeResponse($response, 1);
1369 }
1370
1371 if (!is_array($items)) {
1372 return $result;
1373 }
1374
1375 for ($i = 0, $len = count($items) ; $i < $len ; $i += 2) {
1376 $result[$items[$i]] = $items[$i + 1];
1377 }
1378
1379 $this->data['STATUS:'.$mailbox] = $result;
1380
1381 return $result;
1382 }
1383
1384 return false;
1385 }
1386
1387 /**
1388 * Executes EXPUNGE command
1389 *
1390 * @param string $mailbox Mailbox name
1391 * @param string|array $messages Message UIDs to expunge
1392 *
1393 * @return boolean True on success, False on error
1394 */
1395 public function expunge($mailbox, $messages = null)
1396 {
1397 if (!$this->select($mailbox)) {
1398 return false;
1399 }
1400
1401 if (!$this->data['READ-WRITE']) {
1402 $this->setError(self::ERROR_READONLY, "Mailbox is read-only");
1403
1404 return false;
1405 }
1406
1407 // Clear internal status cache
1408 $this->clear_status_cache($mailbox);
1409
1410 if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) {
1411 $messages = self::compressMessageSet($messages);
1412 $result = $this->execute('UID EXPUNGE', [$messages], self::COMMAND_NORESPONSE);
1413 } else {
1414 $result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE);
1415 }
1416
1417 if ($result == self::ERROR_OK) {
1418 $this->selected = null; // state has changed, need to reselect
1419
1420 return true;
1421 }
1422
1423 return false;
1424 }
1425
1426 /**
1427 * Executes CLOSE command
1428 *
1429 * @return boolean True on success, False on error
1430 * @since 0.5
1431 */
1432 public function close()
1433 {
1434 $result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE);
1435
1436 if ($result == self::ERROR_OK) {
1437 $this->selected = null;
1438
1439 return true;
1440 }
1441
1442 return false;
1443 }
1444
1445 /**
1446 * Folder subscription (SUBSCRIBE)
1447 *
1448 * @param string $mailbox Mailbox name
1449 *
1450 * @return boolean True on success, False on error
1451 */
1452 public function subscribe($mailbox)
1453 {
1454 $result = $this->execute(
1455 'SUBSCRIBE',
1456 [$this->escape($mailbox)],
1457 self::COMMAND_NORESPONSE
1458 );
1459
1460 return $result == self::ERROR_OK;
1461 }
1462
1463 /**
1464 * Folder unsubscription (UNSUBSCRIBE)
1465 *
1466 * @param string $mailbox Mailbox name
1467 *
1468 * @return boolean True on success, False on error
1469 */
1470 public function unsubscribe($mailbox)
1471 {
1472 $result = $this->execute(
1473 'UNSUBSCRIBE',
1474 [$this->escape($mailbox)],
1475 self::COMMAND_NORESPONSE
1476 );
1477
1478 return $result == self::ERROR_OK;
1479 }
1480
1481 /**
1482 * Folder creation (CREATE)
1483 *
1484 * @param string $mailbox Mailbox name
1485 * @param array $types Optional folder types (RFC 6154)
1486 *
1487 * @return bool True on success, False on error
1488 */
1489 public function createFolder($mailbox, $types = null)
1490 {
1491 $args = [$this->escape($mailbox)];
1492
1493 // RFC 6154: CREATE-SPECIAL-USE
1494 if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) {
1495 $args[] = '(USE ('.implode(' ', $types).'))';
1496 }
1497
1498 $result = $this->execute('CREATE', $args, self::COMMAND_RAW_LASTLINE);
1499
1500 $this->rawLastLine = $result[1];
1501
1502 return $result[0] == self::ERROR_OK;
1503 }
1504
1505 /**
1506 * Folder renaming (RENAME)
1507 *
1508 * @param string $mailbox Mailbox name
1509 *
1510 * @return bool True on success, False on error
1511 */
1512 public function renameFolder($from, $to)
1513 {
1514 $result = $this->execute(
1515 'RENAME',
1516 [$this->escape($from), $this->escape($to)],
1517 self::COMMAND_NORESPONSE
1518 );
1519
1520 return $result == self::ERROR_OK;
1521 }
1522
1523 /**
1524 * Executes DELETE command
1525 *
1526 * @param string $mailbox Mailbox name
1527 *
1528 * @return boolean True on success, False on error
1529 */
1530 public function deleteFolder($mailbox)
1531 {
1532 $result = $this->execute(
1533 'DELETE',
1534 [$this->escape($mailbox)],
1535 self::COMMAND_NORESPONSE
1536 );
1537
1538 return $result == self::ERROR_OK;
1539 }
1540
1541 /**
1542 * Removes all messages in a folder
1543 *
1544 * @param string $mailbox Mailbox name
1545 *
1546 * @return boolean True on success, False on error
1547 */
1548 public function clearFolder($mailbox)
1549 {
1550 if ($this->countMessages($mailbox) > 0) {
1551 $res = $this->flag($mailbox, '1:*', 'DELETED');
1552 }
1553
1554 if ($res) {
1555 if ($this->selected === $mailbox) {
1556 $res = $this->close();
1557 } else {
1558 $res = $this->expunge($mailbox);
1559 }
1560 }
1561
1562 return $res;
1563 }
1564
1565 /**
1566 * Returns list of mailboxes
1567 *
1568 * @param string $ref Reference name
1569 * @param string $mailbox Mailbox name
1570 * @param array $return_opts (see self::_listMailboxes)
1571 * @param array $select_opts (see self::_listMailboxes)
1572 *
1573 * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
1574 * is requested, False on error.
1575 */
1576 public function listMailboxes($ref, $mailbox, $return_opts = [], $select_opts = [])
1577 {
1578 return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts);
1579 }
1580
1581 /**
1582 * Returns list of subscribed mailboxes
1583 *
1584 * @param string $ref Reference name
1585 * @param string $mailbox Mailbox name
1586 * @param array $return_opts (see self::_listMailboxes)
1587 *
1588 * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
1589 * is requested, False on error.
1590 */
1591 public function listSubscribed($ref, $mailbox, $return_opts = [])
1592 {
1593 return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null);
1594 }
1595
1596 /**
1597 * IMAP LIST/LSUB command
1598 *
1599 * @param string $ref Reference name
1600 * @param string $mailbox Mailbox name
1601 * @param bool $subscribed Enables returning subscribed mailboxes only
1602 * @param array $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED)
1603 * Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN,
1604 * MYRIGHTS, SUBSCRIBED, CHILDREN
1605 * @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED)
1606 * Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE,
1607 * SPECIAL-USE (RFC6154)
1608 *
1609 * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
1610 * is requested, False on error.
1611 */
1612 protected function _listMailboxes(
1613 $ref,
1614 $mailbox,
1615 $subscribed = false,
1616 $return_opts = [],
1617 $select_opts = []
1618 ) {
1619 if (!strlen($mailbox)) {
1620 $mailbox = '*';
1621 }
1622
1623 $args = [];
1624 $rets = [];
1625
1626 if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) {
1627 $select_opts = (array)$select_opts;
1628
1629 $args[] = '('.implode(' ', $select_opts).')';
1630 }
1631
1632 $args[] = $this->escape($ref);
1633 $args[] = $this->escape($mailbox);
1634
1635 if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) {
1636 $ext_opts = ['SUBSCRIBED', 'CHILDREN'];
1637 $rets = array_intersect($return_opts, $ext_opts);
1638 $return_opts = array_diff($return_opts, $rets);
1639 }
1640
1641 if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) {
1642 $lstatus = true;
1643 $status_opts = ['MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN'];
1644 $opts = array_diff($return_opts, $status_opts);
1645 $status_opts = array_diff($return_opts, $opts);
1646
1647 if (!empty($status_opts)) {
1648 $rets[] = 'STATUS ('.implode(' ', $status_opts).')';
1649 }
1650
1651 if (!empty($opts)) {
1652 $rets = array_merge($rets, $opts);
1653 }
1654 } else {
1655 $lstatus = false;
1656 }
1657
1658 if (!empty($rets)) {
1659 $args[] = 'RETURN ('.implode(' ', $rets).')';
1660 }
1661
1662 [$code, $response] = $this->execute($subscribed ? 'LSUB' : 'LIST', $args);
1663
1664 if ($code == self::ERROR_OK) {
1665 $folders = [];
1666 $last = 0;
1667 $pos = 0;
1668 $response .= "\r\n";
1669
1670 while ($pos = strpos($response, "\r\n", $pos + 1)) {
1671 // literal string, not real end-of-command-line
1672 if ($response[$pos - 1] == '}') {
1673 continue;
1674 }
1675
1676 $line = substr($response, $last, $pos - $last);
1677 $last = $pos + 2;
1678
1679 if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) {
1680 continue;
1681 }
1682
1683 $cmd = strtoupper($m[1]);
1684 $line = substr($line, strlen($m[0]));
1685
1686 // * LIST (<options>) <delimiter> <mailbox>
1687 if ($cmd == 'LIST' || $cmd == 'LSUB') {
1688 [$opts, $delim, $mailbox] = $this->tokenizeResponse($line, 3);
1689
1690 // Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879)
1691 if ($delim) {
1692 $mailbox = rtrim($mailbox, $delim);
1693 }
1694
1695 // Add to result array
1696 if (!$lstatus) {
1697 $folders[] = $mailbox;
1698 } else {
1699 $folders[$mailbox] = [];
1700 }
1701
1702 // store folder options
1703 if ($cmd == 'LIST') {
1704 // Add to options array
1705 if (empty($this->data['LIST'][$mailbox])) {
1706 $this->data['LIST'][$mailbox] = $opts;
1707 } elseif (!empty($opts)) {
1708 $this->data['LIST'][$mailbox] = array_unique(
1709 array_merge(
1710 $this->data['LIST'][$mailbox],
1711 $opts
1712 )
1713 );
1714 }
1715 }
1716 } elseif ($lstatus) {
1717 // * STATUS <mailbox> (<result>)
1718 if ($cmd == 'STATUS') {
1719 [$mailbox, $status] = $this->tokenizeResponse($line, 2);
1720
1721 for ($i = 0, $len = count($status) ; $i < $len ; $i += 2) {
1722 [$name, $value] = $this->tokenizeResponse($status, 2);
1723 $folders[$mailbox][$name] = $value;
1724 }
1725 } // * MYRIGHTS <mailbox> <acl>
1726 elseif ($cmd == 'MYRIGHTS') {
1727 [$mailbox, $acl] = $this->tokenizeResponse($line, 2);
1728 $folders[$mailbox]['MYRIGHTS'] = $acl;
1729 }
1730 }
1731 }
1732
1733 return $folders;
1734 }
1735
1736 return false;
1737 }
1738
1739 /**
1740 * Returns count of all messages in a folder
1741 *
1742 * @param string $mailbox Mailbox name
1743 *
1744 * @return int Number of messages, False on error
1745 */
1746 public function countMessages($mailbox)
1747 {
1748 if ($this->selected === $mailbox && isset($this->data['EXISTS'])) {
1749 return $this->data['EXISTS'];
1750 }
1751
1752 // Check internal cache
1753 $cache = $this->data['STATUS:'.$mailbox];
1754 if (!empty($cache) && isset($cache['MESSAGES'])) {
1755 return (int)$cache['MESSAGES'];
1756 }
1757
1758 // Try STATUS (should be faster than SELECT)
1759 $counts = $this->status($mailbox);
1760 if (is_array($counts)) {
1761 return (int)$counts['MESSAGES'];
1762 }
1763
1764 return false;
1765 }
1766
1767 /**
1768 * Returns count of messages with \Recent flag in a folder
1769 *
1770 * @param string $mailbox Mailbox name
1771 *
1772 * @return int Number of messages, False on error
1773 */
1774 public function countRecent($mailbox)
1775 {
1776 if ($this->selected === $mailbox && isset($this->data['RECENT'])) {
1777 return $this->data['RECENT'];
1778 }
1779
1780 // Check internal cache
1781 $cache = $this->data['STATUS:'.$mailbox];
1782 if (!empty($cache) && isset($cache['RECENT'])) {
1783 return (int)$cache['RECENT'];
1784 }
1785
1786 // Try STATUS (should be faster than SELECT)
1787 $counts = $this->status($mailbox, ['RECENT']);
1788 if (is_array($counts)) {
1789 return (int)$counts['RECENT'];
1790 }
1791
1792 return false;
1793 }
1794
1795 /**
1796 * Returns count of messages without \Seen flag in a specified folder
1797 *
1798 * @param string $mailbox Mailbox name
1799 *
1800 * @return int Number of messages, False on error
1801 */
1802 public function countUnseen($mailbox)
1803 {
1804 // Check internal cache
1805 $cache = $this->data['STATUS:'.$mailbox];
1806 if (!empty($cache) && isset($cache['UNSEEN'])) {
1807 return (int)$cache['UNSEEN'];
1808 }
1809
1810 // Try STATUS (should be faster than SELECT+SEARCH)
1811 $counts = $this->status($mailbox);
1812 if (is_array($counts)) {
1813 return (int)$counts['UNSEEN'];
1814 }
1815
1816 // Invoke SEARCH as a fallback
1817 $index = $this->search($mailbox, 'ALL UNSEEN', false, ['COUNT']);
1818 if (!$index->is_error()) {
1819 return $index->count();
1820 }
1821
1822 return false;
1823 }
1824
1825 /**
1826 * Executes ID command (RFC2971)
1827 *
1828 * @param array $items Client identification information key/value hash
1829 *
1830 * @return array|false Server identification information key/value hash, False on error
1831 * @since 0.6
1832 */
1833 public function id($items = [])
1834 {
1835 if (is_array($items) && !empty($items)) {
1836 foreach ($items as $key => $value) {
1837 $args[] = $this->escape($key, true);
1838 $args[] = $this->escape($value, true);
1839 }
1840 }
1841
1842 [$code, $response] = $this->execute(
1843 'ID',
1844 [!empty($args) ? '('.implode(' ', (array)$args).')' : $this->escape(null)],
1845 0,
1846 '/^\* ID /i'
1847 );
1848
1849 if ($code == self::ERROR_OK && $response) {
1850 $response = substr($response, 5); // remove prefix "* ID "
1851 $items = $this->tokenizeResponse($response, 1);
1852 $result = [];
1853
1854 if (is_array($items)) {
1855 for ($i = 0, $len = count($items) ; $i < $len ; $i += 2) {
1856 $result[$items[$i]] = $items[$i + 1];
1857 }
1858 }
1859
1860 return $result;
1861 }
1862
1863 return false;
1864 }
1865
1866 /**
1867 * Executes ENABLE command (RFC5161)
1868 *
1869 * @param mixed $extension Extension name to enable (or array of names)
1870 *
1871 * @return array|bool List of enabled extensions, False on error
1872 * @since 0.6
1873 */
1874 public function enable($extension)
1875 {
1876 if (empty($extension)) {
1877 return false;
1878 }
1879
1880 if (!$this->hasCapability('ENABLE')) {
1881 return false;
1882 }
1883
1884 if (!is_array($extension)) {
1885 $extension = [$extension];
1886 }
1887
1888 if (!empty($this->extensions_enabled)) {
1889 // check if all extensions are already enabled
1890 $diff = array_diff($extension, $this->extensions_enabled);
1891
1892 if (empty($diff)) {
1893 return $extension;
1894 }
1895
1896 // Make sure the mailbox isn't selected, before enabling extension(s)
1897 if ($this->selected !== null) {
1898 $this->close();
1899 }
1900 }
1901
1902 [$code, $response] = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i');
1903
1904 if ($code == self::ERROR_OK && $response) {
1905 $response = substr($response, 10); // remove prefix "* ENABLED "
1906 $result = (array)$this->tokenizeResponse($response);
1907
1908 $this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result));
1909
1910 return $this->extensions_enabled;
1911 }
1912
1913 return false;
1914 }
1915
1916 /**
1917 * Executes SORT command
1918 *
1919 * @param string $mailbox Mailbox name
1920 * @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
1921 * @param string $criteria Searching criteria
1922 * @param bool $return_uid Enables UID SORT usage
1923 * @param string $encoding Character set
1924 *
1925 * @return rcube_result_index Response data
1926 */
1927 public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
1928 {
1929 $old_sel = $this->selected;
1930 $supported = ['ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO'];
1931 $field = strtoupper($field);
1932
1933 if ($field == 'INTERNALDATE') {
1934 $field = 'ARRIVAL';
1935 }
1936
1937 if (!in_array($field, $supported)) {
1938 return new ResultIndex($mailbox);
1939 }
1940
1941 if (!$this->select($mailbox)) {
1942 return new ResultIndex($mailbox);
1943 }
1944
1945 // return empty result when folder is empty and we're just after SELECT
1946 if ($old_sel != $mailbox && !$this->data['EXISTS']) {
1947 return new ResultIndex($mailbox, '* SORT');
1948 }
1949
1950 // RFC 5957: SORT=DISPLAY
1951 if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) {
1952 $field = 'DISPLAY'.$field;
1953 }
1954
1955 $encoding = $encoding ? trim($encoding) : 'US-ASCII';
1956 $criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL';
1957
1958 [$code, $response] = $this->execute(
1959 $return_uid ? 'UID SORT' : 'SORT',
1960 ["($field)", $encoding, $criteria]
1961 );
1962
1963 if ($code != self::ERROR_OK) {
1964 $response = null;
1965 }
1966
1967 return new ResultIndex($mailbox, $response);
1968 }
1969
1970 /**
1971 * Executes THREAD command
1972 *
1973 * @param string $mailbox Mailbox name
1974 * @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS)
1975 * @param string $criteria Searching criteria
1976 * @param bool $return_uid Enables UIDs in result instead of sequence numbers
1977 * @param string $encoding Character set
1978 *
1979 * @return rcube_result_thread Thread data
1980 */
1981 public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
1982 {
1983 $old_sel = $this->selected;
1984
1985 if (!$this->select($mailbox)) {
1986 return new ResultThread($mailbox);
1987 }
1988
1989 // return empty result when folder is empty and we're just after SELECT
1990 if ($old_sel != $mailbox && !$this->data['EXISTS']) {
1991 return new ResultThread($mailbox, '* THREAD');
1992 }
1993
1994 $encoding = $encoding ? trim($encoding) : 'US-ASCII';
1995 $algorithm = $algorithm ? trim($algorithm) : 'REFERENCES';
1996 $criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL';
1997
1998 [$code, $response] = $this->execute(
1999 $return_uid ? 'UID THREAD' : 'THREAD',
2000 [$algorithm, $encoding, $criteria]
2001 );
2002
2003 if ($code != self::ERROR_OK) {
2004 $response = null;
2005 }
2006
2007 return new ResultThread($mailbox, $response);
2008 }
2009
2010 /**
2011 * Executes SEARCH command
2012 *
2013 * @param string $mailbox Mailbox name
2014 * @param string $criteria Searching criteria
2015 * @param bool $return_uid Enable UID in result instead of sequence ID
2016 * @param array $items Return items (MIN, MAX, COUNT, ALL)
2017 *
2018 * @return rcube_result_index Result data
2019 */
2020 public function search($mailbox, $criteria, $return_uid = false, $items = [])
2021 {
2022 $old_sel = $this->selected;
2023
2024 if (!$this->select($mailbox)) {
2025 return new ResultIndex($mailbox);
2026 }
2027
2028 // return empty result when folder is empty and we're just after SELECT
2029 if ($old_sel != $mailbox && !$this->data['EXISTS']) {
2030 return new ResultIndex($mailbox, '* SEARCH');
2031 }
2032
2033 // If ESEARCH is supported always use ALL
2034 // but not when items are specified or using simple id2uid search
2035 if (empty($items) && preg_match('/[^0-9]/', $criteria)) {
2036 $items = ['ALL'];
2037 }
2038
2039 $esearch = empty($items) ? false : $this->getCapability('ESEARCH');
2040 $criteria = trim($criteria);
2041 $params = '';
2042
2043 // RFC4731: ESEARCH
2044 if (!empty($items) && $esearch) {
2045 $params .= 'RETURN ('.implode(' ', $items).')';
2046 }
2047
2048 if (!empty($criteria)) {
2049 $params .= ($params ? ' ' : '').$criteria;
2050 } else {
2051 $params .= 'ALL';
2052 }
2053
2054 [$code, $response] = $this->execute(
2055 $return_uid ? 'UID SEARCH' : 'SEARCH',
2056 [$params]
2057 );
2058
2059 if ($code != self::ERROR_OK) {
2060 $response = null;
2061 }
2062
2063 return new ResultIndex($mailbox, $response);
2064 }
2065
2066 /**
2067 * Simulates SORT command by using FETCH and sorting.
2068 *
2069 * @param string $mailbox Mailbox name
2070 * @param string|array $message_set Searching criteria (list of messages to return)
2071 * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
2072 * @param bool $skip_deleted Makes that DELETED messages will be skipped
2073 * @param bool $uidfetch Enables UID FETCH usage
2074 * @param bool $return_uid Enables returning UIDs instead of IDs
2075 *
2076 * @return rcube_result_index Response data
2077 */
2078 public function index(
2079 $mailbox,
2080 $message_set,
2081 $index_field = '',
2082 $skip_deleted = true,
2083 $uidfetch = false,
2084 $return_uid = false
2085 ) {
2086 $msg_index = $this->fetchHeaderIndex(
2087 $mailbox,
2088 $message_set,
2089 $index_field,
2090 $skip_deleted,
2091 $uidfetch,
2092 $return_uid
2093 );
2094
2095 if (!empty($msg_index)) {
2096 asort($msg_index); // ASC
2097 $msg_index = array_keys($msg_index);
2098 $msg_index = '* SEARCH '.implode(' ', $msg_index);
2099 } else {
2100 $msg_index = is_array($msg_index) ? '* SEARCH' : null;
2101 }
2102
2103 return new ResultIndex($mailbox, $msg_index);
2104 }
2105
2106 /**
2107 * Fetches specified header/data value for a set of messages.
2108 *
2109 * @param string $mailbox Mailbox name
2110 * @param string|array $message_set Searching criteria (list of messages to return)
2111 * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
2112 * @param bool $skip_deleted Makes that DELETED messages will be skipped
2113 * @param bool $uidfetch Enables UID FETCH usage
2114 * @param bool $return_uid Enables returning UIDs instead of IDs
2115 *
2116 * @return array|bool List of header values or False on failure
2117 */
2118 public function fetchHeaderIndex(
2119 $mailbox,
2120 $message_set,
2121 $index_field = '',
2122 $skip_deleted = true,
2123 $uidfetch = false,
2124 $return_uid = false
2125 ) {
2126 if (is_array($message_set)) {
2127 if (!($message_set = $this->compressMessageSet($message_set))) {
2128 return false;
2129 }
2130 } else {
2131 [$from_idx, $to_idx] = explode(':', $message_set);
2132 if (empty($message_set) ||
2133 (isset($to_idx) && $to_idx != '*' && (int)$from_idx > (int)$to_idx)
2134 ) {
2135 return false;
2136 }
2137 }
2138
2139 $index_field = empty($index_field) ? 'DATE' : strtoupper($index_field);
2140
2141 $fields_a['DATE'] = 1;
2142 $fields_a['INTERNALDATE'] = 4;
2143 $fields_a['ARRIVAL'] = 4;
2144 $fields_a['FROM'] = 1;
2145 $fields_a['REPLY-TO'] = 1;
2146 $fields_a['SENDER'] = 1;
2147 $fields_a['TO'] = 1;
2148 $fields_a['CC'] = 1;
2149 $fields_a['SUBJECT'] = 1;
2150 $fields_a['UID'] = 2;
2151 $fields_a['SIZE'] = 2;
2152 $fields_a['SEEN'] = 3;
2153 $fields_a['RECENT'] = 3;
2154 $fields_a['DELETED'] = 3;
2155
2156 if (!($mode = $fields_a[$index_field])) {
2157 return false;
2158 }
2159
2160 // Select the mailbox
2161 if (!$this->select($mailbox)) {
2162 return false;
2163 }
2164
2165 // build FETCH command string
2166 $key = $this->nextTag();
2167 $cmd = $uidfetch ? 'UID FETCH' : 'FETCH';
2168 $fields = [];
2169
2170 if ($return_uid) {
2171 $fields[] = 'UID';
2172 }
2173 if ($skip_deleted) {
2174 $fields[] = 'FLAGS';
2175 }
2176
2177 if ($mode == 1) {
2178 if ($index_field == 'DATE') {
2179 $fields[] = 'INTERNALDATE';
2180 }
2181 $fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]";
2182 } elseif ($mode == 2) {
2183 if ($index_field == 'SIZE') {
2184 $fields[] = 'RFC822.SIZE';
2185 } elseif (!$return_uid || $index_field != 'UID') {
2186 $fields[] = $index_field;
2187 }
2188 } elseif ($mode == 3 && !$skip_deleted) {
2189 $fields[] = 'FLAGS';
2190 } elseif ($mode == 4) {
2191 $fields[] = 'INTERNALDATE';
2192 }
2193
2194 $request = "$key $cmd $message_set (".implode(' ', $fields).")";
2195
2196 if (!$this->putLine($request)) {
2197 $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2198
2199 return false;
2200 }
2201
2202 $result = [];
2203
2204 do {
2205 $line = rtrim($this->readLine(200));
2206 $line = $this->multLine($line);
2207
2208 if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
2209 $id = $m[1];
2210 $flags = null;
2211
2212 if ($return_uid) {
2213 if (preg_match('/UID ([0-9]+)/', $line, $matches)) {
2214 $id = (int)$matches[1];
2215 } else {
2216 continue;
2217 }
2218 }
2219 if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
2220 $flags = explode(' ', strtoupper($matches[1]));
2221 if (in_array('\\DELETED', $flags)) {
2222 continue;
2223 }
2224 }
2225
2226 if ($mode == 1 && $index_field == 'DATE') {
2227 if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) {
2228 $value = preg_replace(['/^"*[a-z]+:/i'], '', $matches[1]);
2229 $value = trim($value);
2230 $result[$id] = Utils::strtotime($value);
2231 }
2232 // non-existent/empty Date: header, use INTERNALDATE
2233 if (empty($result[$id])) {
2234 if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
2235 $result[$id] = Utils::strtotime($matches[1]);
2236 } else {
2237 $result[$id] = 0;
2238 }
2239 }
2240 } elseif ($mode == 1) {
2241 if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) {
2242 $value = preg_replace(['/^"*[a-z]+:/i', '/\s+$/sm'], ['', ''], $matches[2]);
2243 $result[$id] = trim($value);
2244 } else {
2245 $result[$id] = '';
2246 }
2247 } elseif ($mode == 2) {
2248 if (preg_match('/'.$index_field.' ([0-9]+)/', $line, $matches)) {
2249 $result[$id] = trim($matches[1]);
2250 } else {
2251 $result[$id] = 0;
2252 }
2253 } elseif ($mode == 3) {
2254 if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
2255 $flags = explode(' ', $matches[1]);
2256 }
2257 $result[$id] = in_array("\\".$index_field, (array)$flags) ? 1 : 0;
2258 } elseif ($mode == 4) {
2259 if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
2260 $result[$id] = Utils::strtotime($matches[1]);
2261 } else {
2262 $result[$id] = 0;
2263 }
2264 }
2265 }
2266 } while (!$this->startsWith($line, $key, true, true));
2267
2268 return $result;
2269 }
2270
2271 /**
2272 * Returns message sequence identifier
2273 *
2274 * @param string $mailbox Mailbox name
2275 * @param int $uid Message unique identifier (UID)
2276 *
2277 * @return int Message sequence identifier
2278 */
2279 public function UID2ID($mailbox, $uid)
2280 {
2281 if ($uid > 0) {
2282 $index = $this->search($mailbox, "UID $uid");
2283
2284 if ($index->count() == 1) {
2285 $arr = $index->get();
2286
2287 return (int)$arr[0];
2288 }
2289 }
2290 }
2291
2292 /**
2293 * Returns message unique identifier (UID)
2294 *
2295 * @param string $mailbox Mailbox name
2296 * @param int $uid Message sequence identifier
2297 *
2298 * @return int Message unique identifier
2299 */
2300 public function ID2UID($mailbox, $id)
2301 {
2302 if (empty($id) || $id < 0) {
2303 return null;
2304 }
2305
2306 if (!$this->select($mailbox)) {
2307 return null;
2308 }
2309
2310 if ($uid = $this->data['UID-MAP'][$id]) {
2311 return $uid;
2312 }
2313
2314 if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) {
2315 return null;
2316 }
2317
2318 $index = $this->search($mailbox, $id, true);
2319
2320 if ($index->count() == 1) {
2321 $arr = $index->get();
2322
2323 return $this->data['UID-MAP'][$id] = (int)$arr[0];
2324 }
2325 }
2326
2327 /**
2328 * Sets flag of the message(s)
2329 *
2330 * @param string $mailbox Mailbox name
2331 * @param string|array $messages Message UID(s)
2332 * @param string $flag Flag name
2333 *
2334 * @return bool True on success, False on failure
2335 */
2336 public function flag($mailbox, $messages, $flag)
2337 {
2338 return $this->modFlag($mailbox, $messages, $flag, '+');
2339 }
2340
2341 /**
2342 * Unsets flag of the message(s)
2343 *
2344 * @param string $mailbox Mailbox name
2345 * @param string|array $messages Message UID(s)
2346 * @param string $flag Flag name
2347 *
2348 * @return bool True on success, False on failure
2349 */
2350 public function unflag($mailbox, $messages, $flag)
2351 {
2352 return $this->modFlag($mailbox, $messages, $flag, '-');
2353 }
2354
2355 /**
2356 * Changes flag of the message(s)
2357 *
2358 * @param string $mailbox Mailbox name
2359 * @param string|array $messages Message UID(s)
2360 * @param string $flag Flag name
2361 * @param string $mod Modifier [+|-]. Default: "+".
2362 *
2363 * @return bool True on success, False on failure
2364 */
2365 protected function modFlag($mailbox, $messages, $flag, $mod = '+')
2366 {
2367 if (!$flag) {
2368 return false;
2369 }
2370
2371 if (!$this->select($mailbox)) {
2372 return false;
2373 }
2374
2375 if (!$this->data['READ-WRITE']) {
2376 $this->setError(self::ERROR_READONLY, "Mailbox is read-only");
2377
2378 return false;
2379 }
2380
2381 if (isset($this->flags[strtoupper($flag)]) && $this->flags[strtoupper($flag)]) {
2382 $flag = $this->flags[strtoupper($flag)];
2383 }
2384
2385 // if PERMANENTFLAGS is not specified all flags are allowed
2386 if (!empty($this->data['PERMANENTFLAGS'])
2387 && !in_array($flag, (array)$this->data['PERMANENTFLAGS'])
2388 && !in_array('\\*', (array)$this->data['PERMANENTFLAGS'])
2389 ) {
2390 return false;
2391 }
2392
2393 // Clear internal status cache
2394 if ($flag == 'SEEN') {
2395 unset($this->data['STATUS:'.$mailbox]['UNSEEN']);
2396 }
2397
2398 if ($mod != '+' && $mod != '-') {
2399 $mod = '+';
2400 }
2401
2402 $result = $this->execute(
2403 'UID STORE',
2404 [
2405 $this->compressMessageSet($messages),
2406 $mod.'FLAGS.SILENT',
2407 "($flag)",
2408 ],
2409 self::COMMAND_NORESPONSE
2410 );
2411
2412 return $result == self::ERROR_OK;
2413 }
2414
2415 /**
2416 * Copies message(s) from one folder to another
2417 *
2418 * @param string|array $messages Message UID(s)
2419 * @param string $from Mailbox name
2420 * @param string $to Destination mailbox name
2421 *
2422 * @return bool True on success, False on failure
2423 */
2424 public function copy($messages, $from, $to)
2425 {
2426 // Clear last COPYUID data
2427 unset($this->data['COPYUID']);
2428
2429 if (!$this->select($from)) {
2430 return false;
2431 }
2432
2433 // Clear internal status cache
2434 unset($this->data['STATUS:'.$to]);
2435
2436 $result = $this->execute(
2437 'UID COPY',
2438 [
2439 $this->compressMessageSet($messages),
2440 $this->escape($to),
2441 ],
2442 self::COMMAND_NORESPONSE
2443 );
2444
2445 return $result == self::ERROR_OK;
2446 }
2447
2448 /**
2449 * Moves message(s) from one folder to another.
2450 *
2451 * @param string|array $messages Message UID(s)
2452 * @param string $from Mailbox name
2453 * @param string $to Destination mailbox name
2454 *
2455 * @return bool True on success, False on failure
2456 */
2457 public function move($messages, $from, $to)
2458 {
2459 if (!$this->select($from)) {
2460 return false;
2461 }
2462
2463 if (!$this->data['READ-WRITE']) {
2464 $this->setError(self::ERROR_READONLY, "Mailbox is read-only");
2465
2466 return false;
2467 }
2468
2469 // use MOVE command (RFC 6851)
2470 if ($this->hasCapability('MOVE')) {
2471 // Clear last COPYUID data
2472 unset($this->data['COPYUID']);
2473
2474 // Clear internal status cache
2475 unset($this->data['STATUS:'.$to]);
2476 $this->clear_status_cache($from);
2477
2478 $result = $this->execute(
2479 'UID MOVE',
2480 [
2481 $this->compressMessageSet($messages),
2482 $this->escape($to),
2483 ],
2484 self::COMMAND_NORESPONSE
2485 );
2486
2487 return $result == self::ERROR_OK;
2488 }
2489
2490 // use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE
2491 $result = $this->copy($messages, $from, $to);
2492
2493 if ($result) {
2494 // Clear internal status cache
2495 unset($this->data['STATUS:'.$from]);
2496
2497 $result = $this->flag($from, $messages, 'DELETED');
2498
2499 if ($messages == '*') {
2500 // CLOSE+SELECT should be faster than EXPUNGE
2501 $this->close();
2502 } else {
2503 $this->expunge($from, $messages);
2504 }
2505 }
2506
2507 return $result;
2508 }
2509
2510 /**
2511 * FETCH command (RFC3501)
2512 *
2513 * @param string $mailbox Mailbox name
2514 * @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
2515 * @param bool $is_uid True if $message_set contains UIDs
2516 * @param array $query_items FETCH command data items
2517 * @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query
2518 * @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query
2519 *
2520 * @return array List of rcube_message_header elements, False on error
2521 * @since 0.6
2522 */
2523 public function fetch(
2524 $mailbox,
2525 $message_set,
2526 $is_uid = false,
2527 $query_items = [],
2528 $mod_seq = null,
2529 $vanished = false
2530 ) {
2531 if (!$this->select($mailbox)) {
2532 return false;
2533 }
2534
2535 $message_set = $this->compressMessageSet($message_set);
2536 $result = [];
2537
2538 $key = $this->nextTag();
2539 $cmd = ($is_uid ? 'UID ' : '').'FETCH';
2540 $request = "$key $cmd $message_set (".implode(' ', $query_items).")";
2541
2542 if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) {
2543 $request .= " (CHANGEDSINCE $mod_seq".($vanished ? " VANISHED" : '').")";
2544 }
2545
2546 if (!$this->putLine($request)) {
2547 $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2548
2549 return false;
2550 }
2551
2552 do {
2553 $line = $this->readFullLine(4096);
2554
2555 if (!$line) {
2556 break;
2557 }
2558
2559 // Sample reply line:
2560 // * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen)
2561 // INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...)
2562 // BODY[HEADER.FIELDS ...
2563
2564 if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
2565 $id = intval($m[1]);
2566
2567 $result[$id] = new MessageHeader;
2568 $result[$id]->id = $id;
2569 $result[$id]->subject = '';
2570 $result[$id]->messageID = 'mid:'.$id;
2571
2572 $headers = null;
2573 $lines = [];
2574 $line = substr($line, strlen($m[0]) + 2);
2575 $ln = 0;
2576
2577 // Tokenize response and assign to object properties
2578 while (@list($name, $value) = $this->tokenizeResponse($line, 2)) {
2579 if ($name == 'UID') {
2580 $result[$id]->uid = intval($value);
2581 } elseif ($name == 'RFC822.SIZE') {
2582 $result[$id]->size = intval($value);
2583 } elseif ($name == 'RFC822.TEXT') {
2584 $result[$id]->body = $value;
2585 } elseif ($name == 'INTERNALDATE') {
2586 $result[$id]->internaldate = $value;
2587 $result[$id]->date = $value;
2588 $result[$id]->timestamp = Utils::strtotime($value);
2589 } elseif ($name == 'FLAGS') {
2590 if (!empty($value)) {
2591 foreach ((array)$value as $flag) {
2592 $flag = str_replace(['$', "\\"], '', $flag);
2593 $flag = strtoupper($flag);
2594
2595 $result[$id]->flags[$flag] = true;
2596 }
2597 }
2598 } elseif ($name == 'MODSEQ') {
2599 $result[$id]->modseq = $value[0];
2600 } elseif ($name == 'ENVELOPE') {
2601 $result[$id]->envelope = $value;
2602 } elseif ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) {
2603 if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) {
2604 $value = [$value];
2605 }
2606 $result[$id]->bodystructure = $value;
2607 } elseif ($name == 'RFC822') {
2608 $result[$id]->body = $value;
2609 } elseif (stripos($name, 'BODY[') === 0) {
2610 $name = str_replace(']', '', substr($name, 5));
2611
2612 if ($name == 'HEADER.FIELDS') {
2613 // skip ']' after headers list
2614 $this->tokenizeResponse($line, 1);
2615 $headers = $this->tokenizeResponse($line, 1);
2616 } elseif (strlen($name)) {
2617 $result[$id]->bodypart[$name] = $value;
2618 } else {
2619 $result[$id]->body = $value;
2620 }
2621 }
2622 }
2623
2624 // create array with header field:data
2625 if (!empty($headers)) {
2626 $headers = explode("\n", trim($headers));
2627 foreach ($headers as $resln) {
2628 if (ord($resln[0]) <= 32) {
2629 $lines[$ln] .= (empty($lines[$ln]) ? '' : "\n").trim($resln);
2630 } else {
2631 $lines[++$ln] = trim($resln);
2632 }
2633 }
2634
2635 foreach ($lines as $str) {
2636 [$field, $string] = explode(':', $str, 2);
2637
2638 $field = strtolower($field);
2639 $string = preg_replace('/\n[\t\s]*/', ' ', trim($string));
2640
2641 switch ($field) {
2642 case 'date';
2643 $string = substr($string, 0, 128);
2644 $result[$id]->date = $string;
2645 $result[$id]->timestamp = Utils::strtotime($string);
2646 break;
2647 case 'to':
2648 $result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string);
2649 break;
2650 case 'from':
2651 case 'subject':
2652 $string = substr($string, 0, 2048);
2653 case 'cc':
2654 case 'bcc':
2655 case 'references':
2656 $result[$id]->{$field} = $string;
2657 break;
2658 case 'reply-to':
2659 $result[$id]->replyto = $string;
2660 break;
2661 case 'content-transfer-encoding':
2662 $result[$id]->encoding = substr($string, 0, 32);
2663 break;
2664 case 'content-type':
2665 $ctype_parts = preg_split('/[; ]+/', $string);
2666 $result[$id]->ctype = strtolower(array_shift($ctype_parts));
2667 if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) {
2668 $result[$id]->charset = $regs[1];
2669 }
2670 break;
2671 case 'in-reply-to':
2672 $result[$id]->in_reply_to = str_replace(["\n", '<', '>'], '', $string);
2673 break;
2674 case 'return-receipt-to':
2675 case 'disposition-notification-to':
2676 case 'x-confirm-reading-to':
2677 $result[$id]->mdn_to = substr($string, 0, 2048);
2678 break;
2679 case 'message-id':
2680 $result[$id]->messageID = substr($string, 0, 2048);
2681 break;
2682 case 'x-priority':
2683 if (preg_match('/^(\d+)/', $string, $matches)) {
2684 $result[$id]->priority = intval($matches[1]);
2685 }
2686 break;
2687 default:
2688 if (strlen($field) < 3) {
2689 break;
2690 }
2691 if ($result[$id]->others[$field]) {
2692 $string = array_merge((array)$result[$id]->others[$field], (array)$string);
2693 }
2694 $result[$id]->others[$field] = $string;
2695 }
2696 }
2697 }
2698 }
2699 // VANISHED response (QRESYNC RFC5162)
2700 // Sample: * VANISHED (EARLIER) 300:310,405,411
2701 elseif (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
2702 $line = substr($line, strlen($match[0]));
2703 $v_data = $this->tokenizeResponse($line, 1);
2704
2705 $this->data['VANISHED'] = $v_data;
2706 }
2707 } while (!$this->startsWith($line, $key, true));
2708
2709 return $result;
2710 }
2711
2712 /**
2713 * Returns message(s) data (flags, headers, etc.)
2714 *
2715 * @param string $mailbox Mailbox name
2716 * @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
2717 * @param bool $is_uid True if $message_set contains UIDs
2718 * @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
2719 * @param array $add_headers List of additional headers
2720 *
2721 * @return bool|array List of rcube_message_header elements, False on error
2722 */
2723 public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = [])
2724 {
2725 $query_items = ['UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE'];
2726 $headers = [
2727 'DATE',
2728 'FROM',
2729 'TO',
2730 'SUBJECT',
2731 'CONTENT-TYPE',
2732 'CC',
2733 'REPLY-TO',
2734 'LIST-POST',
2735 'DISPOSITION-NOTIFICATION-TO',
2736 'X-PRIORITY',
2737 ];
2738
2739 if (!empty($add_headers)) {
2740 $add_headers = array_map('strtoupper', $add_headers);
2741 $headers = array_unique(array_merge($headers, $add_headers));
2742 }
2743
2744 if ($bodystr) {
2745 $query_items[] = 'BODYSTRUCTURE';
2746 }
2747
2748 $query_items[] = 'BODY.PEEK[HEADER.FIELDS ('.implode(' ', $headers).')]';
2749
2750 return $this->fetch($mailbox, $message_set, $is_uid, $query_items);
2751 }
2752
2753 /**
2754 * Returns message data (flags, headers, etc.)
2755 *
2756 * @param string $mailbox Mailbox name
2757 * @param int $id Message sequence identifier or UID
2758 * @param bool $is_uid True if $id is an UID
2759 * @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
2760 * @param array $add_headers List of additional headers
2761 *
2762 * @return bool|rcube_message_header Message data, False on error
2763 */
2764 public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = [])
2765 {
2766 $a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers);
2767 if (is_array($a)) {
2768 return array_shift($a);
2769 }
2770
2771 return false;
2772 }
2773
2774 /**
2775 * Sort messages by specified header field
2776 *
2777 * @param array $messages Array of rcube_message_header objects
2778 * @param string $field Name of the property to sort by
2779 * @param string $flag Sorting order (ASC|DESC)
2780 *
2781 * @return array Sorted input array
2782 */
2783 public static function sortHeaders($messages, $field, $flag)
2784 {
2785 $field = empty($field) ? 'uid' : strtolower($field);
2786 $order = empty($flag) ? 'ASC' : strtoupper($flag);
2787 $index = [];
2788
2789 reset($messages);
2790
2791 // Create an index
2792 foreach ($messages as $key => $headers) {
2793 switch ($field) {
2794 case 'arrival':
2795 $field = 'internaldate';
2796 // no-break
2797 case 'date':
2798 case 'internaldate':
2799 case 'timestamp':
2800 $value = Utils::strtotime($headers->$field);
2801 if (!$value && $field != 'timestamp') {
2802 $value = $headers->timestamp;
2803 }
2804
2805 break;
2806
2807 default:
2808 // @TODO: decode header value, convert to UTF-8
2809 $value = $headers->$field;
2810 if (is_string($value)) {
2811 $value = str_replace('"', '', $value);
2812
2813 if ($field == 'subject') {
2814 $value = preg_replace('/^(Re:\s*|Fwd:\s*|Fw:\s*)+/i', '', $value);
2815 }
2816 }
2817 }
2818
2819 $index[$key] = $value;
2820 }
2821
2822 $sort_order = $flag == 'ASC' ? SORT_ASC : SORT_DESC;
2823 $sort_flags = SORT_STRING | SORT_FLAG_CASE;
2824
2825 if (in_array($field, ['arrival', 'date', 'internaldate', 'timestamp'])) {
2826 $sort_flags = SORT_NUMERIC;
2827 }
2828
2829 array_multisort($index, $sort_order, $sort_flags, $messages);
2830
2831 return $messages;
2832 }
2833
2834 /**
2835 * Fetch MIME headers of specified message parts
2836 *
2837 * @param string $mailbox Mailbox name
2838 * @param int $uid Message UID
2839 * @param array $parts Message part identifiers
2840 * @param bool $mime Use MIME instad of HEADER
2841 *
2842 * @return array|bool Array containing headers string for each specified body
2843 * False on failure.
2844 */
2845 public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true)
2846 {
2847 if (!$this->select($mailbox)) {
2848 return false;
2849 }
2850
2851 $result = false;
2852 $parts = (array)$parts;
2853 $key = $this->nextTag();
2854 $peeks = [];
2855 $type = $mime ? 'MIME' : 'HEADER';
2856
2857 // format request
2858 foreach ($parts as $part) {
2859 $peeks[] = "BODY.PEEK[$part.$type]";
2860 }
2861
2862 $request = "$key UID FETCH $uid (".implode(' ', $peeks).')';
2863
2864 // send request
2865 if (!$this->putLine($request)) {
2866 $this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command");
2867
2868 return false;
2869 }
2870
2871 do {
2872 $line = $this->readLine(1024);
2873
2874 if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) {
2875 $line = ltrim(substr($line, strlen($m[0])));
2876 while (preg_match('/^BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) {
2877 $line = substr($line, strlen($matches[0]));
2878 $result[$matches[1]] = trim($this->multLine($line));
2879 $line = $this->readLine(1024);
2880 }
2881 }
2882 } while (!$this->startsWith($line, $key, true));
2883
2884 return $result;
2885 }
2886
2887 /**
2888 * Fetches message part header
2889 */
2890 public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null)
2891 {
2892 $part = empty($part) ? 'HEADER' : $part.'.MIME';
2893
2894 return $this->handlePartBody($mailbox, $id, $is_uid, $part);
2895 }
2896
2897 /**
2898 * Fetches body of the specified message part
2899 */
2900 public function handlePartBody($mailbox, $id, $is_uid = false, $part = '', $encoding = null, $print = null, $file = null, $formatted = false, $max_bytes = 0)
2901 {
2902 if (!$this->select($mailbox)) {
2903 return false;
2904 }
2905
2906 $binary = true;
2907
2908 do {
2909 if (!$initiated) {
2910 switch ($encoding) {
2911 case 'base64':
2912 $mode = 1;
2913 break;
2914 case 'quoted-printable':
2915 $mode = 2;
2916 break;
2917 case 'x-uuencode':
2918 case 'x-uue':
2919 case 'uue':
2920 case 'uuencode':
2921 $mode = 3;
2922 break;
2923 default:
2924 $mode = 0;
2925 }
2926
2927 // Use BINARY extension when possible (and safe)
2928 $binary = $binary && $mode && preg_match('/^[0-9.]+$/', $part) && $this->hasCapability('BINARY');
2929 $fetch_mode = $binary ? 'BINARY' : 'BODY';
2930 $partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : '';
2931
2932 // format request
2933 $key = $this->nextTag();
2934 $cmd = ($is_uid ? 'UID ' : '').'FETCH';
2935 $request = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)";
2936 $result = false;
2937 $found = false;
2938 $initiated = true;
2939
2940 // send request
2941 if (!$this->putLine($request)) {
2942 $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2943
2944 return false;
2945 }
2946
2947 if ($binary) {
2948 // WARNING: Use $formatted argument with care, this may break binary data stream
2949 $mode = -1;
2950 }
2951 }
2952
2953 $line = trim($this->readLine(1024));
2954
2955 if (!$line) {
2956 break;
2957 }
2958
2959 // handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request
2960 if ($binary && !$found && preg_match('/^'.$key.' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) {
2961 $binary = $initiated = false;
2962 continue;
2963 }
2964
2965 // skip irrelevant untagged responses (we have a result already)
2966 if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) {
2967 continue;
2968 }
2969
2970 $line = $m[2];
2971
2972 // handle one line response
2973 if ($line[0] == '(' && substr($line, -1) == ')') {
2974 // tokenize content inside brackets
2975 // the content can be e.g.: (UID 9844 BODY[2.4] NIL)
2976 $tokens = $this->tokenizeResponse(preg_replace('/(^\(|\)$)/', '', $line));
2977
2978 for ($i = 0 ; $i < count($tokens) ; $i += 2) {
2979 if (preg_match('/^(BODY|BINARY)/i', $tokens[$i])) {
2980 $result = $tokens[$i + 1];
2981 $found = true;
2982 break;
2983 }
2984 }
2985
2986 if ($result !== false) {
2987 if ($mode == 1) {
2988 $result = base64_decode($result);
2989 } elseif ($mode == 2) {
2990 $result = quoted_printable_decode($result);
2991 } elseif ($mode == 3) {
2992 $result = convert_uudecode($result);
2993 }
2994 }
2995 } // response with string literal
2996 elseif (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
2997 $bytes = (int)$m[1];
2998 $prev = '';
2999 $found = true;
3000
3001 // empty body
3002 if (!$bytes) {
3003 $result = '';
3004 } else {
3005 while ($bytes > 0) {
3006 $line = $this->readLine(8192);
3007
3008 if ($line === null) {
3009 break;
3010 }
3011
3012 $len = strlen($line);
3013
3014 if ($len > $bytes) {
3015 $line = substr($line, 0, $bytes);
3016 $len = strlen($line);
3017 }
3018 $bytes -= $len;
3019
3020 // BASE64
3021 if ($mode == 1) {
3022 $line = preg_replace('|[^a-zA-Z0-9+=/]|', '', $line);
3023 // create chunks with proper length for base64 decoding
3024 $line = $prev.$line;
3025 $length = strlen($line);
3026 if ($length % 4) {
3027 $length = floor($length / 4) * 4;
3028 $prev = substr($line, $length);
3029 $line = substr($line, 0, $length);
3030 } else {
3031 $prev = '';
3032 }
3033 $line = base64_decode($line);
3034 } // QUOTED-PRINTABLE
3035 elseif ($mode == 2) {
3036 $line = rtrim($line, "\t\r\0\x0B");
3037 $line = quoted_printable_decode($line);
3038 } // UUENCODE
3039 elseif ($mode == 3) {
3040 $line = rtrim($line, "\t\r\n\0\x0B");
3041 if ($line == 'end' || preg_match('/^begin\s+[0-7]+\s+.+$/', $line)) {
3042 continue;
3043 }
3044 $line = convert_uudecode($line);
3045 } // default
3046 elseif ($formatted) {
3047 $line = rtrim($line, "\t\r\n\0\x0B")."\n";
3048 }
3049
3050 if ($file) {
3051 if (fwrite($file, $line) === false) {
3052 break;
3053 }
3054 } elseif ($print) {
3055 echo $line;
3056 } else {
3057 $result .= $line;
3058 }
3059 }
3060 }
3061 }
3062 } while (!$this->startsWith($line, $key, true) || !$initiated);
3063
3064 if ($result !== false) {
3065 if ($file) {
3066 return fwrite($file, $result);
3067 } elseif ($print) {
3068 echo $result;
3069
3070 return true;
3071 }
3072
3073 return $result;
3074 }
3075
3076 return false;
3077 }
3078
3079 /**
3080 * Handler for IMAP APPEND command
3081 *
3082 * @param string $mailbox Mailbox name
3083 * @param string|array $message The message source string or array (of strings and file pointers)
3084 * @param array $flags Message flags
3085 * @param string $date Message internal date
3086 * @param bool $binary Enable BINARY append (RFC3516)
3087 *
3088 * @return string|bool On success APPENDUID response (if available) or True, False on failure
3089 */
3090 public function append($mailbox, &$message, $flags = [], $date = null, $binary = false)
3091 {
3092 unset($this->data['APPENDUID']);
3093
3094 if ($mailbox === null || $mailbox === '') {
3095 return false;
3096 }
3097
3098 $binary = $binary && $this->getCapability('BINARY');
3099 $literal_plus = !$binary && isset($this->prefs['literal+']) && $this->prefs['literal+'];
3100 $len = 0;
3101 $msg = is_array($message) ? $message : [&$message];
3102 $chunk_size = 512000;
3103
3104 for ($i = 0, $cnt = count($msg) ; $i < $cnt ; $i++) {
3105 if (is_resource($msg[$i])) {
3106 $stat = fstat($msg[$i]);
3107 if ($stat === false) {
3108 return false;
3109 }
3110 $len += $stat['size'];
3111 } else {
3112 if (!$binary) {
3113 $msg[$i] = str_replace("\r", '', $msg[$i]);
3114 $msg[$i] = str_replace("\n", "\r\n", $msg[$i]);
3115 }
3116
3117 $len += strlen($msg[$i]);
3118 }
3119 }
3120
3121 if (!$len) {
3122 return false;
3123 }
3124
3125 // build APPEND command
3126 $key = $this->nextTag();
3127 $request = "$key APPEND ".$this->escape($mailbox).' ('.$this->flagsToStr($flags).')';
3128 if (!empty($date)) {
3129 $request .= ' '.$this->escape($date);
3130 }
3131 $request .= ' '.($binary ? '~' : '').'{'.$len.($literal_plus ? '+' : '').'}';
3132
3133 // send APPEND command
3134 if (!$this->putLine($request)) {
3135 $this->setError(self::ERROR_COMMAND, "Failed to send APPEND command");
3136
3137 return false;
3138 }
3139
3140 // Do not wait when LITERAL+ is supported
3141 if (!$literal_plus) {
3142 $line = $this->readReply();
3143
3144 if ($line[0] != '+') {
3145 $this->parseResult($line, 'APPEND: ');
3146
3147 return false;
3148 }
3149 }
3150
3151 foreach ($msg as $msg_part) {
3152 // file pointer
3153 if (is_resource($msg_part)) {
3154 rewind($msg_part);
3155 while (!feof($msg_part) && $this->fp) {
3156 $buffer = fread($msg_part, $chunk_size);
3157 $this->putLine($buffer, false);
3158 }
3159 fclose($msg_part);
3160 } // string
3161 else {
3162 $size = strlen($msg_part);
3163
3164 // Break up the data by sending one chunk (up to 512k) at a time.
3165 // This approach reduces our peak memory usage
3166 for ($offset = 0 ; $offset < $size ; $offset += $chunk_size) {
3167 $chunk = substr($msg_part, $offset, $chunk_size);
3168 if (!$this->putLine($chunk, false)) {
3169 return false;
3170 }
3171 }
3172 }
3173 }
3174
3175 if (!$this->putLine('')) { // \r\n
3176 return false;
3177 }
3178
3179 do {
3180 $line = $this->readLine();
3181 } while (!$this->startsWith($line, $key, true, true));
3182
3183 // Clear internal status cache
3184 unset($this->data['STATUS:'.$mailbox]);
3185
3186 if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) {
3187 return false;
3188 }
3189
3190 if (!empty($this->data['APPENDUID'])) {
3191 return $this->data['APPENDUID'];
3192 }
3193
3194 return true;
3195 }
3196
3197 /**
3198 * Handler for IMAP APPEND command.
3199 *
3200 * @param string $mailbox Mailbox name
3201 * @param string $path Path to the file with message body
3202 * @param string $headers Message headers
3203 * @param array $flags Message flags
3204 * @param string $date Message internal date
3205 * @param bool $binary Enable BINARY append (RFC3516)
3206 *
3207 * @return string|bool On success APPENDUID response (if available) or True, False on failure
3208 */
3209 public function appendFromFile($mailbox, $path, $headers = null, $flags = [], $date = null, $binary = false)
3210 {
3211 // open message file
3212 if (file_exists(realpath($path))) {
3213 $fp = fopen($path, 'r');
3214 }
3215
3216 if (!$fp) {
3217 $this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading");
3218
3219 return false;
3220 }
3221
3222 $message = [];
3223 if ($headers) {
3224 $message[] = trim($headers, "\r\n")."\r\n\r\n";
3225 }
3226 $message[] = $fp;
3227
3228 return $this->append($mailbox, $message, $flags, $date, $binary);
3229 }
3230
3231 /**
3232 * Returns QUOTA information
3233 *
3234 * @param string $mailbox Mailbox name
3235 *
3236 * @return array Quota information
3237 */
3238 public function getQuota($mailbox = null)
3239 {
3240 if ($mailbox === null || $mailbox === '') {
3241 $mailbox = 'INBOX';
3242 }
3243
3244 // a0001 GETQUOTAROOT INBOX
3245 // * QUOTAROOT INBOX user/sample
3246 // * QUOTA user/sample (STORAGE 654 9765)
3247 // a0001 OK Completed
3248
3249 [$code, $response] = $this->execute('GETQUOTAROOT', [$this->escape($mailbox)], 0, '/^\* QUOTA /i');
3250
3251 $result = false;
3252 $min_free = PHP_INT_MAX;
3253 $all = [];
3254
3255 if ($code == self::ERROR_OK) {
3256 foreach (explode("\n", $response) as $line) {
3257 [, , $quota_root] = $this->tokenizeResponse($line, 3);
3258
3259 $quotas = $this->tokenizeResponse($line, 1);
3260
3261 if (empty($quotas)) {
3262 continue;
3263 }
3264
3265 foreach (array_chunk($quotas, 3) as $quota) {
3266 [$type, $used, $total] = $quota;
3267 $type = strtolower($type);
3268
3269 if ($type && $total) {
3270 $all[$quota_root][$type]['used'] = intval($used);
3271 $all[$quota_root][$type]['total'] = intval($total);
3272 }
3273 }
3274
3275 if (empty($all[$quota_root]['storage'])) {
3276 continue;
3277 }
3278
3279 $used = $all[$quota_root]['storage']['used'];
3280 $total = $all[$quota_root]['storage']['total'];
3281 $free = $total - $used;
3282
3283 // calculate lowest available space from all storage quotas
3284 if ($free < $min_free) {
3285 $min_free = $free;
3286 $result['used'] = $used;
3287 $result['total'] = $total;
3288 $result['percent'] = min(100, round(($used / max(1, $total)) * 100));
3289 $result['free'] = 100 - $result['percent'];
3290 }
3291 }
3292 }
3293
3294 if (!empty($result)) {
3295 $result['all'] = $all;
3296 }
3297
3298 return $result;
3299 }
3300
3301 /**
3302 * Send the SETACL command (RFC4314)
3303 *
3304 * @param string $mailbox Mailbox name
3305 * @param string $user User name
3306 * @param mixed $acl ACL string or array
3307 *
3308 * @return boolean True on success, False on failure
3309 *
3310 * @since 0.5-beta
3311 */
3312 public function setACL($mailbox, $user, $acl)
3313 {
3314 if (is_array($acl)) {
3315 $acl = implode('', $acl);
3316 }
3317
3318 $result = $this->execute(
3319 'SETACL',
3320 [
3321 $this->escape($mailbox),
3322 $this->escape($user),
3323 strtolower($acl),
3324 ],
3325 self::COMMAND_NORESPONSE
3326 );
3327
3328 return ($result == self::ERROR_OK);
3329 }
3330
3331 /**
3332 * Send the DELETEACL command (RFC4314)
3333 *
3334 * @param string $mailbox Mailbox name
3335 * @param string $user User name
3336 *
3337 * @return boolean True on success, False on failure
3338 *
3339 * @since 0.5-beta
3340 */
3341 public function deleteACL($mailbox, $user)
3342 {
3343 $result = $this->execute(
3344 'DELETEACL',
3345 [
3346 $this->escape($mailbox),
3347 $this->escape($user),
3348 ],
3349 self::COMMAND_NORESPONSE
3350 );
3351
3352 return ($result == self::ERROR_OK);
3353 }
3354
3355 /**
3356 * Send the GETACL command (RFC4314)
3357 *
3358 * @param string $mailbox Mailbox name
3359 *
3360 * @return array User-rights array on success, NULL on error
3361 * @since 0.5-beta
3362 */
3363 public function getACL($mailbox)
3364 {
3365 [$code, $response] = $this->execute('GETACL', [$this->escape($mailbox)], 0, '/^\* ACL /i');
3366
3367 if ($code == self::ERROR_OK && $response) {
3368 // Parse server response (remove "* ACL ")
3369 $response = substr($response, 6);
3370 $ret = $this->tokenizeResponse($response);
3371 $mbox = array_shift($ret);
3372 $size = count($ret);
3373
3374 // Create user-rights hash array
3375 // @TODO: consider implementing fixACL() method according to RFC4314.2.1.1
3376 // so we could return only standard rights defined in RFC4314,
3377 // excluding 'c' and 'd' defined in RFC2086.
3378 if ($size % 2 == 0) {
3379 for ($i = 0 ; $i < $size ; $i++) {
3380 $ret[$ret[$i]] = str_split($ret[++$i]);
3381 unset($ret[$i - 1]);
3382 unset($ret[$i]);
3383 }
3384
3385 return $ret;
3386 }
3387
3388 $this->setError(self::ERROR_COMMAND, "Incomplete ACL response");
3389 }
3390 }
3391
3392 /**
3393 * Send the LISTRIGHTS command (RFC4314)
3394 *
3395 * @param string $mailbox Mailbox name
3396 * @param string $user User name
3397 *
3398 * @return array List of user rights
3399 * @since 0.5-beta
3400 */
3401 public function listRights($mailbox, $user)
3402 {
3403 [$code, $response] = $this->execute(
3404 'LISTRIGHTS',
3405 [$this->escape($mailbox), $this->escape($user)],
3406 0,
3407 '/^\* LISTRIGHTS /i'
3408 );
3409
3410 if ($code == self::ERROR_OK && $response) {
3411 // Parse server response (remove "* LISTRIGHTS ")
3412 $response = substr($response, 13);
3413
3414 $ret_mbox = $this->tokenizeResponse($response, 1);
3415 $ret_user = $this->tokenizeResponse($response, 1);
3416 $granted = $this->tokenizeResponse($response, 1);
3417 $optional = trim($response);
3418
3419 return [
3420 'granted' => str_split($granted),
3421 'optional' => explode(' ', $optional),
3422 ];
3423 }
3424 }
3425
3426 /**
3427 * Send the MYRIGHTS command (RFC4314)
3428 *
3429 * @param string $mailbox Mailbox name
3430 *
3431 * @return array MYRIGHTS response on success, NULL on error
3432 * @since 0.5-beta
3433 */
3434 public function myRights($mailbox)
3435 {
3436 [$code, $response] = $this->execute('MYRIGHTS', [$this->escape($mailbox)], 0, '/^\* MYRIGHTS /i');
3437
3438 if ($code == self::ERROR_OK && $response) {
3439 // Parse server response (remove "* MYRIGHTS ")
3440 $response = substr($response, 11);
3441
3442 $ret_mbox = $this->tokenizeResponse($response, 1);
3443 $rights = $this->tokenizeResponse($response, 1);
3444
3445 return str_split($rights);
3446 }
3447 }
3448
3449 /**
3450 * Send the SETMETADATA command (RFC5464)
3451 *
3452 * @param string $mailbox Mailbox name
3453 * @param array $entries Entry-value array (use NULL value as NIL)
3454 *
3455 * @return boolean True on success, False on failure
3456 * @since 0.5-beta
3457 */
3458 public function setMetadata($mailbox, $entries)
3459 {
3460 if (!is_array($entries) || empty($entries)) {
3461 $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
3462
3463 return false;
3464 }
3465
3466 foreach ($entries as $name => $value) {
3467 $entries[$name] = $this->escape($name).' '.$this->escape($value, true);
3468 }
3469
3470 $entries = implode(' ', $entries);
3471 $result = $this->execute(
3472 'SETMETADATA',
3473 [
3474 $this->escape($mailbox),
3475 '('.$entries.')',
3476 ],
3477 self::COMMAND_NORESPONSE
3478 );
3479
3480 return ($result == self::ERROR_OK);
3481 }
3482
3483 /**
3484 * Send the SETMETADATA command with NIL values (RFC5464)
3485 *
3486 * @param string $mailbox Mailbox name
3487 * @param array $entries Entry names array
3488 *
3489 * @return boolean True on success, False on failure
3490 *
3491 * @since 0.5-beta
3492 */
3493 public function deleteMetadata($mailbox, $entries)
3494 {
3495 if (!is_array($entries) && !empty($entries)) {
3496 $entries = explode(' ', $entries);
3497 }
3498
3499 if (empty($entries)) {
3500 $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
3501
3502 return false;
3503 }
3504
3505 foreach ($entries as $entry) {
3506 $data[$entry] = null;
3507 }
3508
3509 return $this->setMetadata($mailbox, $data);
3510 }
3511
3512 /**
3513 * Send the GETMETADATA command (RFC5464)
3514 *
3515 * @param string $mailbox Mailbox name
3516 * @param array $entries Entries
3517 * @param array $options Command options (with MAXSIZE and DEPTH keys)
3518 *
3519 * @return array GETMETADATA result on success, NULL on error
3520 *
3521 * @since 0.5-beta
3522 */
3523 public function getMetadata($mailbox, $entries, $options = [])
3524 {
3525 if (!is_array($entries)) {
3526 $entries = [$entries];
3527 }
3528
3529 // create entries string
3530 foreach ($entries as $idx => $name) {
3531 $entries[$idx] = $this->escape($name);
3532 }
3533
3534 $optlist = '';
3535 $entlist = '('.implode(' ', $entries).')';
3536
3537 // create options string
3538 if (is_array($options)) {
3539 $options = array_change_key_case($options, CASE_UPPER);
3540 $opts = [];
3541
3542 if (!empty($options['MAXSIZE'])) {
3543 $opts[] = 'MAXSIZE '.intval($options['MAXSIZE']);
3544 }
3545 if (!empty($options['DEPTH'])) {
3546 $opts[] = 'DEPTH '.intval($options['DEPTH']);
3547 }
3548
3549 if ($opts) {
3550 $optlist = '('.implode(' ', $opts).')';
3551 }
3552 }
3553
3554 $optlist .= ($optlist ? ' ' : '').$entlist;
3555
3556 [$code, $response] = $this->execute('GETMETADATA', [
3557 $this->escape($mailbox),
3558 $optlist,
3559 ]);
3560
3561 if ($code == self::ERROR_OK) {
3562 $result = [];
3563 $data = $this->tokenizeResponse($response);
3564
3565 // The METADATA response can contain multiple entries in a single
3566 // response or multiple responses for each entry or group of entries
3567 for ($i = 0, $size = count($data) ; $i < $size ; $i++) {
3568 if ($data[$i] === '*'
3569 && $data[++$i] === 'METADATA'
3570 && is_string($mbox = $data[++$i])
3571 && is_array($data[++$i])
3572 ) {
3573 for ($x = 0, $size2 = count($data[$i]) ; $x < $size2 ; $x += 2) {
3574 if ($data[$i][$x + 1] !== null) {
3575 $result[$mbox][$data[$i][$x]] = $data[$i][$x + 1];
3576 }
3577 }
3578 }
3579 }
3580
3581 return $result;
3582 }
3583 }
3584
3585 /**
3586 * Send the SETANNOTATION command (draft-daboo-imap-annotatemore)
3587 *
3588 * @param string $mailbox Mailbox name
3589 * @param array $data Data array where each item is an array with
3590 * three elements: entry name, attribute name, value
3591 *
3592 * @return boolean True on success, False on failure
3593 * @since 0.5-beta
3594 */
3595 public function setAnnotation($mailbox, $data)
3596 {
3597 if (!is_array($data) || empty($data)) {
3598 $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
3599
3600 return false;
3601 }
3602
3603 foreach ($data as $entry) {
3604 // ANNOTATEMORE drafts before version 08 require quoted parameters
3605 $entries[] = sprintf(
3606 '%s (%s %s)',
3607 $this->escape($entry[0], true),
3608 $this->escape($entry[1], true),
3609 $this->escape($entry[2], true)
3610 );
3611 }
3612
3613 $entries = implode(' ', $entries);
3614 $result = $this->execute('SETANNOTATION', [
3615 $this->escape($mailbox),
3616 $entries,
3617 ], self::COMMAND_NORESPONSE);
3618
3619 return ($result == self::ERROR_OK);
3620 }
3621
3622 /**
3623 * Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore)
3624 *
3625 * @param string $mailbox Mailbox name
3626 * @param array $data Data array where each item is an array with
3627 * two elements: entry name and attribute name
3628 *
3629 * @return boolean True on success, False on failure
3630 *
3631 * @since 0.5-beta
3632 */
3633 public function deleteAnnotation($mailbox, $data)
3634 {
3635 if (!is_array($data) || empty($data)) {
3636 $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
3637
3638 return false;
3639 }
3640
3641 return $this->setAnnotation($mailbox, $data);
3642 }
3643
3644 /**
3645 * Send the GETANNOTATION command (draft-daboo-imap-annotatemore)
3646 *
3647 * @param string $mailbox Mailbox name
3648 * @param array $entries Entries names
3649 * @param array $attribs Attribs names
3650 *
3651 * @return array Annotations result on success, NULL on error
3652 *
3653 * @since 0.5-beta
3654 */
3655 public function getAnnotation($mailbox, $entries, $attribs)
3656 {
3657 if (!is_array($entries)) {
3658 $entries = [$entries];
3659 }
3660
3661 // create entries string
3662 // ANNOTATEMORE drafts before version 08 require quoted parameters
3663 foreach ($entries as $idx => $name) {
3664 $entries[$idx] = $this->escape($name, true);
3665 }
3666 $entries = '('.implode(' ', $entries).')';
3667
3668 if (!is_array($attribs)) {
3669 $attribs = [$attribs];
3670 }
3671
3672 // create attributes string
3673 foreach ($attribs as $idx => $name) {
3674 $attribs[$idx] = $this->escape($name, true);
3675 }
3676 $attribs = '('.implode(' ', $attribs).')';
3677
3678 [$code, $response] = $this->execute('GETANNOTATION', [
3679 $this->escape($mailbox),
3680 $entries,
3681 $attribs,
3682 ]);
3683
3684 if ($code == self::ERROR_OK) {
3685 $result = [];
3686 $data = $this->tokenizeResponse($response);
3687
3688 // Here we returns only data compatible with METADATA result format
3689 if (!empty($data) && ($size = count($data))) {
3690 for ($i = 0 ; $i < $size ; $i++) {
3691 $entry = $data[$i];
3692 if (isset($mbox) && is_array($entry)) {
3693 $attribs = $entry;
3694 $entry = $last_entry;
3695 } elseif ($entry == '*') {
3696 if ($data[$i + 1] == 'ANNOTATION') {
3697 $mbox = $data[$i + 2];
3698 unset($data[$i]); // "*"
3699 unset($data[++$i]); // "ANNOTATION"
3700 unset($data[++$i]); // Mailbox
3701 } // get rid of other untagged responses
3702 else {
3703 unset($mbox);
3704 unset($data[$i]);
3705 }
3706 continue;
3707 } elseif (isset($mbox)) {
3708 $attribs = $data[++$i];
3709 } else {
3710 unset($data[$i]);
3711 continue;
3712 }
3713
3714 if (!empty($attribs)) {
3715 for ($x = 0, $len = count($attribs) ; $x < $len ;) {
3716 $attr = $attribs[$x++];
3717 $value = $attribs[$x++];
3718 if ($attr == 'value.priv' && $value !== null) {
3719 $result[$mbox]['/private'.$entry] = $value;
3720 } elseif ($attr == 'value.shared' && $value !== null) {
3721 $result[$mbox]['/shared'.$entry] = $value;
3722 }
3723 }
3724 }
3725 $last_entry = $entry;
3726 unset($data[$i]);
3727 }
3728 }
3729
3730 return $result;
3731 }
3732 }
3733
3734 /**
3735 * Returns BODYSTRUCTURE for the specified message.
3736 *
3737 * @param string $mailbox Folder name
3738 * @param int $id Message sequence number or UID
3739 * @param bool $is_uid True if $id is an UID
3740 *
3741 * @return array/bool Body structure array or False on error.
3742 * @since 0.6
3743 */
3744 public function getStructure($mailbox, $id, $is_uid = false)
3745 {
3746 $result = $this->fetch($mailbox, $id, $is_uid, ['BODYSTRUCTURE']);
3747
3748 if (is_array($result)) {
3749 $result = array_shift($result);
3750
3751 return $result->bodystructure;
3752 }
3753
3754 return false;
3755 }
3756
3757 /**
3758 * Returns data of a message part according to specified structure.
3759 *
3760 * @param array $structure Message structure (getStructure() result)
3761 * @param string $part Message part identifier
3762 *
3763 * @return array Part data as hash array (type, encoding, charset, size)
3764 */
3765 public static function getStructurePartData($structure, $part)
3766 {
3767 $part_a = self::getStructurePartArray($structure, $part);
3768 $data = [];
3769
3770 if (empty($part_a)) {
3771 return $data;
3772 }
3773
3774 // content-type
3775 if (is_array($part_a[0])) {
3776 $data['type'] = 'multipart';
3777 } else {
3778 $data['type'] = strtolower($part_a[0]);
3779 $data['subtype'] = strtolower($part_a[1]);
3780 $data['encoding'] = strtolower($part_a[5]);
3781
3782 // charset
3783 if (is_array($part_a[2])) {
3784 foreach ($part_a[2] as $key => $val) {
3785 if (strcasecmp($val, 'charset') == 0) {
3786 $data['charset'] = $part_a[2][$key + 1];
3787 break;
3788 }
3789 }
3790 }
3791 }
3792
3793 // size
3794 $data['size'] = intval($part_a[6]);
3795
3796 return $data;
3797 }
3798
3799 public static function getStructurePartArray($a, $part)
3800 {
3801 if (!is_array($a)) {
3802 return false;
3803 }
3804
3805 if (empty($part)) {
3806 return $a;
3807 }
3808
3809 $ctype = is_string($a[0]) && is_string($a[1]) ? $a[0].'/'.$a[1] : '';
3810
3811 if (strcasecmp($ctype, 'message/rfc822') == 0) {
3812 $a = $a[8];
3813 }
3814
3815 if (strpos($part, '.') > 0) {
3816 $orig_part = $part;
3817 $pos = strpos($part, '.');
3818 $rest = substr($orig_part, $pos + 1);
3819 $part = substr($orig_part, 0, $pos);
3820
3821 return self::getStructurePartArray($a[$part - 1], $rest);
3822 } elseif ($part > 0) {
3823 return (is_array($a[$part - 1])) ? $a[$part - 1] : $a;
3824 }
3825 }
3826
3827 /**
3828 * Creates next command identifier (tag)
3829 *
3830 * @return string Command identifier
3831 * @since 0.5-beta
3832 */
3833 public function nextTag()
3834 {
3835 $this->cmd_num++;
3836 $this->cmd_tag = sprintf('A%04d', $this->cmd_num);
3837
3838 return $this->cmd_tag;
3839 }
3840
3841 /**
3842 * Sends IMAP command and parses result
3843 *
3844 * @param string $command IMAP command
3845 * @param array $arguments Command arguments
3846 * @param int $options Execution options
3847 * @param string $filter Line filter (regexp)
3848 *
3849 * @return mixed Response code or list of response code and data
3850 * @since 0.5-beta
3851 */
3852 public function execute($command, $arguments = [], $options = 0, $filter = null)
3853 {
3854 $tag = $this->nextTag();
3855 $query = $tag.' '.$command;
3856 $noresp = ($options & self::COMMAND_NORESPONSE);
3857 $response = $noresp ? null : '';
3858
3859 if (!empty($arguments)) {
3860 foreach ($arguments as $arg) {
3861 $query .= ' '.self::r_implode($arg);
3862 }
3863 }
3864
3865 // Send command
3866 if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) {
3867 preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches);
3868 $cmd = $matches[1] ? : 'UNKNOWN';
3869 $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
3870
3871 return $noresp ? self::ERROR_COMMAND : [self::ERROR_COMMAND, ''];
3872 }
3873
3874 // Parse response
3875 do {
3876 $line = $this->readFullLine(4096);
3877
3878 if ($response !== null) {
3879 if (!$filter || preg_match($filter, $line)) {
3880 $response .= $line;
3881 }
3882 }
3883
3884 // parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851)
3885 if ($line && $command == 'UID MOVE') {
3886 if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) {
3887 $this->data['COPYUID'] = [$m[1], $m[2]];
3888 }
3889 }
3890 } while (!$this->startsWith($line, $tag.' ', true, true));
3891
3892 $code = $this->parseResult($line, $command.': ');
3893
3894 // Remove last line from response
3895 if ($response) {
3896 if (!$filter) {
3897 $line_len = min(strlen($response), strlen($line));
3898 $response = substr($response, 0, -$line_len);
3899 }
3900
3901 $response = rtrim($response, "\r\n");
3902 }
3903
3904 // optional CAPABILITY response
3905 if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK
3906 && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)
3907 ) {
3908 $this->parseCapability($matches[1], true);
3909 }
3910
3911 // return last line only (without command tag, result and response code)
3912 if ($line && ($options & self::COMMAND_LASTLINE)) {
3913 $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line));
3914 }
3915
3916 // return raw last line only (without command tag, result and response code)
3917 if ($line && ($options & self::COMMAND_RAW_LASTLINE)) {
3918 $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*/i", '', trim($line));
3919 }
3920
3921 return $noresp ? $code : [$code, $response];
3922 }
3923
3924 /**
3925 * Splits IMAP response into string tokens
3926 *
3927 * @param string &$str The IMAP's server response
3928 * @param int $num Number of tokens to return
3929 *
3930 * @return mixed Tokens array or string if $num=1
3931 * @since 0.5-beta
3932 */
3933 public static function tokenizeResponse(&$str, $num = 0)
3934 {
3935 $result = [];
3936
3937 while (!$num || count($result) < $num) {
3938 // remove spaces from the beginning of the string
3939 $str = ltrim($str);
3940
3941 switch ($str[0]) {
3942
3943 // String literal
3944 case '{':
3945 if (($epos = strpos($str, "}\r\n", 1)) == false) {
3946 // error
3947 }
3948 if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) {
3949 // error
3950 }
3951
3952 $result[] = $bytes ? substr($str, $epos + 3, $bytes) : '';
3953 $str = substr($str, $epos + 3 + $bytes);
3954 break;
3955
3956 // Quoted string
3957 case '"':
3958 $len = strlen($str);
3959
3960 for ($pos = 1 ; $pos < $len ; $pos++) {
3961 if ($str[$pos] == '"') {
3962 break;
3963 }
3964 if ($str[$pos] == "\\") {
3965 if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
3966 $pos++;
3967 }
3968 }
3969 }
3970
3971 // we need to strip slashes for a quoted string
3972 $result[] = stripslashes(substr($str, 1, $pos - 1));
3973 $str = substr($str, $pos + 1);
3974 break;
3975
3976 // Parenthesized list
3977 case '(':
3978 $str = substr($str, 1);
3979 $result[] = self::tokenizeResponse($str);
3980 break;
3981
3982 case ')':
3983 $str = substr($str, 1);
3984
3985 return $result;
3986
3987 // String atom, number, astring, NIL, *, %
3988 default:
3989 // empty string
3990 if ($str === '' || $str === null) {
3991 break 2;
3992 }
3993
3994 // excluded chars: SP, CTL, ), DEL
3995 // we do not exclude [ and ] (#1489223)
3996 if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) {
3997 $result[] = $m[1] == 'NIL' ? null : $m[1];
3998 $str = substr($str, strlen($m[1]));
3999 }
4000 break;
4001 }
4002 }
4003
4004 return $num == 1 ? $result[0] : $result;
4005 }
4006
4007 /**
4008 * Joins IMAP command line elements (recursively)
4009 */
4010 protected static function r_implode($element)
4011 {
4012 $string = '';
4013
4014 if (is_array($element)) {
4015 reset($element);
4016 foreach ($element as $value) {
4017 $string .= ' '.self::r_implode($value);
4018 }
4019 } else {
4020 return $element;
4021 }
4022
4023 return '('.trim($string).')';
4024 }
4025
4026 /**
4027 * Converts message identifiers array into sequence-set syntax
4028 *
4029 * @param array $messages Message identifiers
4030 * @param bool $force Forces compression of any size
4031 *
4032 * @return string Compressed sequence-set
4033 */
4034 public static function compressMessageSet($messages, $force = false)
4035 {
4036 // given a comma delimited list of independent mid's,
4037 // compresses by grouping sequences together
4038 if (!is_array($messages)) {
4039 // if less than 255 bytes long, let's not bother
4040 if (!$force && strlen($messages) < 255) {
4041 return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
4042 }
4043
4044 // see if it's already been compressed
4045 if (strpos($messages, ':') !== false) {
4046 return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
4047 }
4048
4049 // separate, then sort
4050 $messages = explode(',', $messages);
4051 }
4052
4053 sort($messages);
4054
4055 $result = [];
4056 $start = $prev = $messages[0];
4057
4058 foreach ($messages as $id) {
4059 $incr = $id - $prev;
4060 if ($incr > 1) { // found a gap
4061 if ($start == $prev) {
4062 $result[] = $prev; // push single id
4063 } else {
4064 $result[] = $start.':'.$prev; // push sequence as start_id:end_id
4065 }
4066 $start = $id; // start of new sequence
4067 }
4068 $prev = $id;
4069 }
4070
4071 // handle the last sequence/id
4072 if ($start == $prev) {
4073 $result[] = $prev;
4074 } else {
4075 $result[] = $start.':'.$prev;
4076 }
4077
4078 // return as comma separated string
4079 $result = implode(',', $result);
4080
4081 return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result;
4082 }
4083
4084 /**
4085 * Converts message sequence-set into array
4086 *
4087 * @param string $messages Message identifiers
4088 *
4089 * @return array List of message identifiers
4090 */
4091 public static function uncompressMessageSet($messages)
4092 {
4093 if (empty($messages)) {
4094 return [];
4095 }
4096
4097 $result = [];
4098 $messages = explode(',', $messages);
4099
4100 foreach ($messages as $idx => $part) {
4101 $items = explode(':', $part);
4102 $max = max($items[0], $items[1] ?? $items[0]);
4103
4104 for ($x = $items[0] ; $x <= $max ; $x++) {
4105 $result[] = (int)$x;
4106 }
4107 unset($messages[$idx]);
4108 }
4109
4110 return $result;
4111 }
4112
4113 /**
4114 * Clear internal status cache
4115 */
4116 protected function clear_status_cache($mailbox)
4117 {
4118 unset($this->data['STATUS:'.$mailbox]);
4119
4120 $keys = ['EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP'];
4121
4122 foreach ($keys as $key) {
4123 unset($this->data[$key]);
4124 }
4125 }
4126
4127 /**
4128 * Clear internal cache of the current mailbox
4129 */
4130 protected function clear_mailbox_cache()
4131 {
4132 $this->clear_status_cache($this->selected);
4133
4134 $keys = [
4135 'UIDNEXT',
4136 'UIDVALIDITY',
4137 'HIGHESTMODSEQ',
4138 'NOMODSEQ',
4139 'PERMANENTFLAGS',
4140 'QRESYNC',
4141 'VANISHED',
4142 'READ-WRITE',
4143 ];
4144
4145 foreach ($keys as $key) {
4146 unset($this->data[$key]);
4147 }
4148 }
4149
4150 /**
4151 * Converts flags array into string for inclusion in IMAP command
4152 *
4153 * @param array $flags Flags (see self::flags)
4154 *
4155 * @return string Space-separated list of flags
4156 */
4157 protected function flagsToStr($flags)
4158 {
4159 foreach ((array)$flags as $idx => $flag) {
4160 if ($flag = $this->flags[strtoupper($flag)]) {
4161 $flags[$idx] = $flag;
4162 }
4163 }
4164
4165 return implode(' ', (array)$flags);
4166 }
4167
4168 /**
4169 * CAPABILITY response parser
4170 */
4171 protected function parseCapability($str, $trusted = false)
4172 {
4173 $str = preg_replace('/^\* CAPABILITY /i', '', $str);
4174
4175 $this->capability = explode(' ', strtoupper($str));
4176
4177 if (!empty($this->prefs['disabled_caps'])) {
4178 $this->capability = array_diff($this->capability, $this->prefs['disabled_caps']);
4179 }
4180
4181 if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) {
4182 $this->prefs['literal+'] = true;
4183 }
4184
4185 if ($trusted) {
4186 $this->capability_readed = true;
4187 }
4188 }
4189
4190 /**
4191 * Escapes a string when it contains special characters (RFC3501)
4192 *
4193 * @param string $string IMAP string
4194 * @param boolean $force_quotes Forces string quoting (for atoms)
4195 *
4196 * @return string String atom, quoted-string or string literal
4197 * @todo lists
4198 */
4199 public static function escape($string, $force_quotes = false)
4200 {
4201 if ($string === null) {
4202 return 'NIL';
4203 }
4204
4205 if ($string === '') {
4206 return '""';
4207 }
4208
4209 // atom-string (only safe characters)
4210 if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) {
4211 return $string;
4212 }
4213
4214 // quoted-string
4215 if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
4216 return '"'.addcslashes($string, '\\"').'"';
4217 }
4218
4219 // literal-string
4220 return sprintf("{%d}\r\n%s", strlen($string), $string);
4221 }
4222
4223 /**
4224 * Set the value of the debugging flag.
4225 *
4226 * @param boolean $debug New value for the debugging flag.
4227 * @param callback $handler Logging handler function
4228 *
4229 * @since 0.5-stable
4230 */
4231 public function setDebug($debug, $handler = null)
4232 {
4233 $this->debug = $debug;
4234 $this->debug_handler = $handler;
4235 }
4236
4237 /**
4238 * Write the given debug text to the current debug output handler.
4239 *
4240 * @param string $message Debug message text.
4241 *
4242 * @since 0.5-stable
4243 */
4244 protected function debug($message)
4245 {
4246 if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) {
4247 $diff = $len - self::DEBUG_LINE_LENGTH;
4248 $message = substr($message, 0, self::DEBUG_LINE_LENGTH)
4249 ."... [truncated $diff bytes]";
4250 }
4251
4252 if ($this->resourceid) {
4253 $message = sprintf('[%s] %s', $this->resourceid, $message);
4254 }
4255
4256 if ($this->debug_handler) {
4257 call_user_func_array($this->debug_handler, [&$this, $message]);
4258 } else {
4259 echo "DEBUG: $message\n";
4260 }
4261 }
4262
4263 public function getRawLastLine()
4264 {
4265 return $this->rawLastLine;
4266 }
4267 }
4268