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