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