Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 */
33
class 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
     * @return 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;
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 === '') {
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 === '') {
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 (!$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|int 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 $password Password
819
     *
820
     * @return resource|int 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, true), $this->escape($password, true)],
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
        $port     = empty($this->prefs['port']) ? 143 : $this->prefs['port'];
1037
        $ssl_mode = $this->prefs['ssl_mode'] ?? null;
1038
 
1039
        // check for SSL
1040
        if (!empty($ssl_mode) && $ssl_mode != 'tls') {
1041
            $host = $ssl_mode . '://' . $host;
1042
        }
1043
 
1044
        if (empty($this->prefs['timeout']) || $this->prefs['timeout'] < 0) {
1045
            $this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout')));
1046
        }
1047
 
1048
        if ($this->debug) {
1049
            // set connection identifier for debug output
1050
            $this->resourceid = strtoupper(substr(md5(microtime() . $host . $this->user), 0, 4));
1051
 
1052
            $_host = ($ssl_mode == 'tls' ? 'tls://' : '') . $host . ':' . $port;
1053
            $this->debug("Connecting to $_host...");
1054
        }
1055
 
1056
        if (!empty($this->prefs['socket_options'])) {
1057
            $options  = array_intersect_key($this->prefs['socket_options'], ['ssl' => 1]);
1058
            $context  = stream_context_create($options);
1059
            $this->fp = stream_socket_client($host . ':' . $port, $errno, $errstr,
1060
                $this->prefs['timeout'], STREAM_CLIENT_CONNECT, $context);
1061
        }
1062
        else {
1063
            $this->fp = @fsockopen($host, $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, $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, $port, $line);
1087
            }
1088
            else {
1089
                $error = sprintf("Empty startup greeting (%s:%d)", $host, $port);
1090
            }
1091
 
1092
            $this->setError(self::ERROR_BAD, $error);
1093
            $this->closeConnection();
1094
            return false;
1095
        }
1096
 
1097
        $this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line));
1098
 
1099
        // RFC3501 [7.1] optional CAPABILITY response
1100
        if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
1101
            $this->parseCapability($matches[1], true);
1102
        }
1103
 
1104
        // TLS connection
1105
        if ($ssl_mode == 'tls' && $this->getCapability('STARTTLS')) {
1106
            $res = $this->execute('STARTTLS');
1107
 
1108
            if (empty($res) || $res[0] != self::ERROR_OK) {
1109
                $this->closeConnection();
1110
                return false;
1111
            }
1112
 
1113
            if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) {
1114
                $crypto_method = $this->prefs['socket_options']['ssl']['crypto_method'];
1115
            }
1116
            else {
1117
                // There is no flag to enable all TLS methods. Net_SMTP
1118
                // handles enabling TLS similarly.
1119
                $crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT
1120
                    | @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
1121
                    | @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
1122
            }
1123
 
1124
            if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) {
1125
                $this->setError(self::ERROR_BAD, "Unable to negotiate TLS");
1126
                $this->closeConnection();
1127
                return false;
1128
            }
1129
 
1130
            // Now we're secure, capabilities need to be reread
1131
            $this->clearCapability();
1132
        }
1133
 
1134
        return true;
1135
    }
1136
 
1137
    /**
1138
     * Initializes environment
1139
     */
1140
    protected function set_prefs($prefs)
1141
    {
1142
        // set preferences
1143
        if (is_array($prefs)) {
1144
            $this->prefs = $prefs;
1145
        }
1146
 
1147
        // set auth method
1148
        if (!empty($this->prefs['auth_type'])) {
1149
            $this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']);
1150
        }
1151
        else {
1152
            $this->prefs['auth_type'] = 'CHECK';
1153
        }
1154
 
1155
        // disabled capabilities
1156
        if (!empty($this->prefs['disabled_caps'])) {
1157
            $this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']);
1158
        }
1159
 
1160
        // additional message flags
1161
        if (!empty($this->prefs['message_flags'])) {
1162
            $this->flags = array_merge($this->flags, $this->prefs['message_flags']);
1163
            unset($this->prefs['message_flags']);
1164
        }
1165
    }
1166
 
1167
    /**
1168
     * Checks connection status
1169
     *
1170
     * @return bool True if connection is active and user is logged in, False otherwise.
1171
     */
1172
    public function connected()
1173
    {
1174
        return $this->fp && $this->logged;
1175
    }
1176
 
1177
    /**
1178
     * Closes connection with logout.
1179
     */
1180
    public function closeConnection()
1181
    {
1182
        if ($this->logged && $this->putLine($this->nextTag() . ' LOGOUT')) {
1183
            $this->readReply();
1184
        }
1185
 
1186
        $this->closeSocket();
1187
        $this->clearCapability();
1188
    }
1189
 
1190
    /**
1191
     * Executes SELECT command (if mailbox is already not in selected state)
1192
     *
1193
     * @param string $mailbox      Mailbox name
1194
     * @param array  $qresync_data QRESYNC data (RFC5162)
1195
     *
1196
     * @return bool True on success, false on error
1197
     */
1198
    public function select($mailbox, $qresync_data = null)
1199
    {
1200
        if (!strlen($mailbox)) {
1201
            return false;
1202
        }
1203
 
1204
        if ($this->selected === $mailbox) {
1205
            return true;
1206
        }
1207
 
1208
        $params = [$this->escape($mailbox)];
1209
 
1210
        // QRESYNC data items
1211
        //    0. the last known UIDVALIDITY,
1212
        //    1. the last known modification sequence,
1213
        //    2. the optional set of known UIDs, and
1214
        //    3. an optional parenthesized list of known sequence ranges and their
1215
        //       corresponding UIDs.
1216
        if (!empty($qresync_data)) {
1217
            if (!empty($qresync_data[2])) {
1218
                $qresync_data[2] = self::compressMessageSet($qresync_data[2]);
1219
            }
1220
 
1221
            $params[] = ['QRESYNC', $qresync_data];
1222
        }
1223
 
1224
        list($code, $response) = $this->execute('SELECT', $params);
1225
 
1226
        if ($code == self::ERROR_OK) {
1227
            $this->clear_mailbox_cache();
1228
 
1229
            $response = explode("\r\n", $response);
1230
            foreach ($response as $line) {
1231
                if (preg_match('/^\* OK \[/i', $line)) {
1232
                    $pos   = strcspn($line, ' ]', 6);
1233
                    $token = strtoupper(substr($line, 6, $pos));
1234
                    $pos   += 7;
1235
 
1236
                    switch ($token) {
1237
                    case 'UIDNEXT':
1238
                    case 'UIDVALIDITY':
1239
                    case 'UNSEEN':
1240
                        if ($len = strspn($line, '0123456789', $pos)) {
1241
                            $this->data[$token] = (int) substr($line, $pos, $len);
1242
                        }
1243
                        break;
1244
 
1245
                    case 'HIGHESTMODSEQ':
1246
                        if ($len = strspn($line, '0123456789', $pos)) {
1247
                            $this->data[$token] = (string) substr($line, $pos, $len);
1248
                        }
1249
                        break;
1250
 
1251
                    case 'NOMODSEQ':
1252
                        $this->data[$token] = true;
1253
                        break;
1254
 
1255
                    case 'PERMANENTFLAGS':
1256
                        $start = strpos($line, '(', $pos);
1257
                        $end   = strrpos($line, ')');
1258
                        if ($start && $end) {
1259
                            $flags = substr($line, $start + 1, $end - $start - 1);
1260
                            $this->data[$token] = explode(' ', $flags);
1261
                        }
1262
                        break;
1263
                    }
1264
                }
1265
                else if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) {
1266
                    $token = strtoupper($match[2]);
1267
                    switch ($token) {
1268
                    case 'EXISTS':
1269
                    case 'RECENT':
1270
                        $this->data[$token] = (int) $match[1];
1271
                        break;
1272
 
1273
                    case 'FETCH':
1274
                        // QRESYNC FETCH response (RFC5162)
1275
                        $line       = substr($line, strlen($match[0]));
1276
                        $fetch_data = $this->tokenizeResponse($line, 1);
1277
                        $data       = ['id' => $match[1]];
1278
 
1279
                        for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) {
1280
                            $data[strtolower($fetch_data[$i])] = $fetch_data[$i+1];
1281
                        }
1282
 
1283
                        $this->data['QRESYNC'][$data['uid']] = $data;
1284
                        break;
1285
                    }
1286
                }
1287
                // QRESYNC VANISHED response (RFC5162)
1288
                else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
1289
                    $line   = substr($line, strlen($match[0]));
1290
                    $v_data = $this->tokenizeResponse($line, 1);
1291
 
1292
                    $this->data['VANISHED'] = $v_data;
1293
                }
1294
            }
1295
 
1296
            $this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY';
1297
            $this->selected = $mailbox;
1298
 
1299
            return true;
1300
        }
1301
 
1302
        return false;
1303
    }
1304
 
1305
    /**
1306
     * Executes STATUS command
1307
     *
1308
     * @param string $mailbox Mailbox name
1309
     * @param array  $items   Additional requested item names. By default
1310
     *                        MESSAGES and UNSEEN are requested. Other defined
1311
     *                        in RFC3501: UIDNEXT, UIDVALIDITY, RECENT
1312
     *
1313
     * @return array Status item-value hash
1314
     * @since 0.5-beta
1315
     */
1316
    public function status($mailbox, $items = [])
1317
    {
1318
        if (!strlen($mailbox)) {
1319
            return false;
1320
        }
1321
 
1322
        if (!in_array('MESSAGES', $items)) {
1323
            $items[] = 'MESSAGES';
1324
        }
1325
        if (!in_array('UNSEEN', $items)) {
1326
            $items[] = 'UNSEEN';
1327
        }
1328
 
1329
        list($code, $response) = $this->execute('STATUS',
1330
            [$this->escape($mailbox), '(' . implode(' ', $items) . ')'], 0, '/^\* STATUS /i');
1331
 
1332
        if ($code == self::ERROR_OK && $response) {
1333
            $result   = [];
1334
            $response = substr($response, 9); // remove prefix "* STATUS "
1335
 
1336
            list($mbox, $items) = $this->tokenizeResponse($response, 2);
1337
 
1338
            // Fix for #1487859. Some buggy server returns not quoted
1339
            // folder name with spaces. Let's try to handle this situation
1340
            if (!is_array($items) && ($pos = strpos($response, '(')) !== false) {
1341
                $response = substr($response, $pos);
1342
                $items    = $this->tokenizeResponse($response, 1);
1343
            }
1344
 
1345
            if (!is_array($items)) {
1346
                return $result;
1347
            }
1348
 
1349
            for ($i=0, $len=count($items); $i<$len; $i += 2) {
1350
                $result[$items[$i]] = $items[$i+1];
1351
            }
1352
 
1353
            $this->data['STATUS:'.$mailbox] = $result;
1354
 
1355
            return $result;
1356
        }
1357
 
1358
        return false;
1359
    }
1360
 
1361
    /**
1362
     * Executes EXPUNGE command
1363
     *
1364
     * @param string       $mailbox  Mailbox name
1365
     * @param string|array $messages Message UIDs to expunge
1366
     *
1367
     * @return bool True on success, False on error
1368
     */
1369
    public function expunge($mailbox, $messages = null)
1370
    {
1371
        if (!$this->select($mailbox)) {
1372
            return false;
1373
        }
1374
 
1375
        if (empty($this->data['READ-WRITE'])) {
1376
            $this->setError(self::ERROR_READONLY, "Mailbox is read-only");
1377
            return false;
1378
        }
1379
 
1380
        // Clear internal status cache
1381
        $this->clear_status_cache($mailbox);
1382
 
1383
        if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) {
1384
            $messages = self::compressMessageSet($messages);
1385
            $result   = $this->execute('UID EXPUNGE', [$messages], self::COMMAND_NORESPONSE);
1386
        }
1387
        else {
1388
            $result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE);
1389
        }
1390
 
1391
        if ($result == self::ERROR_OK) {
1392
            $this->selected = null; // state has changed, need to reselect
1393
            return true;
1394
        }
1395
 
1396
        return false;
1397
    }
1398
 
1399
    /**
1400
     * Executes CLOSE command
1401
     *
1402
     * @return bool True on success, False on error
1403
     * @since 0.5
1404
     */
1405
    public function close()
1406
    {
1407
        $result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE);
1408
 
1409
        if ($result == self::ERROR_OK) {
1410
            $this->selected = null;
1411
            return true;
1412
        }
1413
 
1414
        return false;
1415
    }
1416
 
1417
    /**
1418
     * Folder subscription (SUBSCRIBE)
1419
     *
1420
     * @param string $mailbox Mailbox name
1421
     *
1422
     * @return bool True on success, False on error
1423
     */
1424
    public function subscribe($mailbox)
1425
    {
1426
        $result = $this->execute('SUBSCRIBE', [$this->escape($mailbox)], self::COMMAND_NORESPONSE);
1427
 
1428
        return $result == self::ERROR_OK;
1429
    }
1430
 
1431
    /**
1432
     * Folder unsubscription (UNSUBSCRIBE)
1433
     *
1434
     * @param string $mailbox Mailbox name
1435
     *
1436
     * @return bool True on success, False on error
1437
     */
1438
    public function unsubscribe($mailbox)
1439
    {
1440
        $result = $this->execute('UNSUBSCRIBE', [$this->escape($mailbox)], self::COMMAND_NORESPONSE);
1441
 
1442
        return $result == self::ERROR_OK;
1443
    }
1444
 
1445
    /**
1446
     * Folder creation (CREATE)
1447
     *
1448
     * @param string $mailbox Mailbox name
1449
     * @param array  $types   Optional folder types (RFC 6154)
1450
     *
1451
     * @return bool True on success, False on error
1452
     */
1453
    public function createFolder($mailbox, $types = null)
1454
    {
1455
        $args = [$this->escape($mailbox)];
1456
 
1457
        // RFC 6154: CREATE-SPECIAL-USE
1458
        if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) {
1459
            $args[] = '(USE (' . implode(' ', $types) . '))';
1460
        }
1461
 
1462
        $result = $this->execute('CREATE', $args, self::COMMAND_NORESPONSE);
1463
 
1464
        return $result == self::ERROR_OK;
1465
    }
1466
 
1467
    /**
1468
     * Folder renaming (RENAME)
1469
     *
1470
     * @param string $from Mailbox name
1471
     * @param string $to   Mailbox name
1472
     *
1473
     * @return bool True on success, False on error
1474
     */
1475
    public function renameFolder($from, $to)
1476
    {
1477
        $result = $this->execute('RENAME', [$this->escape($from), $this->escape($to)], self::COMMAND_NORESPONSE);
1478
 
1479
        return $result == self::ERROR_OK;
1480
    }
1481
 
1482
    /**
1483
     * Executes DELETE command
1484
     *
1485
     * @param string $mailbox Mailbox name
1486
     *
1487
     * @return bool True on success, False on error
1488
     */
1489
    public function deleteFolder($mailbox)
1490
    {
1491
        // Unselect the folder to prevent "BYE Fatal error: Mailbox has been (re)moved" on Cyrus IMAP
1492
        if ($this->selected === $mailbox && $this->hasCapability('UNSELECT')) {
1493
            $this->execute('UNSELECT', [], self::COMMAND_NORESPONSE);
1494
        }
1495
 
1496
        $result = $this->execute('DELETE', [$this->escape($mailbox)], self::COMMAND_NORESPONSE);
1497
 
1498
        return $result == self::ERROR_OK;
1499
    }
1500
 
1501
    /**
1502
     * Removes all messages in a folder
1503
     *
1504
     * @param string $mailbox Mailbox name
1505
     *
1506
     * @return bool True on success, False on error
1507
     */
1508
    public function clearFolder($mailbox)
1509
    {
1510
        if ($this->countMessages($mailbox) > 0) {
1511
            $res = $this->flag($mailbox, '1:*', 'DELETED');
1512
        }
1513
        else {
1514
            return true;
1515
        }
1516
 
1517
        if (!empty($res)) {
1518
            if ($this->selected === $mailbox) {
1519
                $res = $this->close();
1520
            }
1521
            else {
1522
                $res = $this->expunge($mailbox);
1523
            }
1524
 
1525
            return $res;
1526
        }
1527
 
1528
        return false;
1529
    }
1530
 
1531
    /**
1532
     * Returns list of mailboxes
1533
     *
1534
     * @param string $ref         Reference name
1535
     * @param string $mailbox     Mailbox name
1536
     * @param array  $return_opts (see self::_listMailboxes)
1537
     * @param array  $select_opts (see self::_listMailboxes)
1538
     *
1539
     * @return array|bool List of mailboxes or hash of options if STATUS/MYRIGHTS response
1540
     *                    is requested, False on error.
1541
     */
1542
    public function listMailboxes($ref, $mailbox, $return_opts = [], $select_opts = [])
1543
    {
1544
        return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts);
1545
    }
1546
 
1547
    /**
1548
     * Returns list of subscribed mailboxes
1549
     *
1550
     * @param string $ref         Reference name
1551
     * @param string $mailbox     Mailbox name
1552
     * @param array  $return_opts (see self::_listMailboxes)
1553
     *
1554
     * @return array|bool List of mailboxes or hash of options if STATUS/MYRIGHTS response
1555
     *                    is requested, False on error.
1556
     */
1557
    public function listSubscribed($ref, $mailbox, $return_opts = [])
1558
    {
1559
        return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null);
1560
    }
1561
 
1562
    /**
1563
     * IMAP LIST/LSUB command
1564
     *
1565
     * @param string $ref         Reference name
1566
     * @param string $mailbox     Mailbox name
1567
     * @param bool   $subscribed  Enables returning subscribed mailboxes only
1568
     * @param array  $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED)
1569
     *                            Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN,
1570
     *                                      MYRIGHTS, SUBSCRIBED, CHILDREN
1571
     * @param array  $select_opts List of selection options (RFC5258: LIST-EXTENDED)
1572
     *                            Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE,
1573
     *                                      SPECIAL-USE (RFC6154)
1574
     *
1575
     * @return array|bool List of mailboxes or hash of options if STATUS/MYRIGHTS response
1576
     *                    is requested, False on error.
1577
     */
1578
    protected function _listMailboxes($ref, $mailbox, $subscribed = false, $return_opts = [], $select_opts = [])
1579
    {
1580
        if (!strlen($mailbox)) {
1581
            $mailbox = '*';
1582
        }
1583
 
1584
        $lstatus = false;
1585
        $args    = [];
1586
        $rets    = [];
1587
 
1588
        if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) {
1589
            $select_opts = (array) $select_opts;
1590
 
1591
            $args[] = '(' . implode(' ', $select_opts) . ')';
1592
        }
1593
 
1594
        $args[] = $this->escape($ref);
1595
        $args[] = $this->escape($mailbox);
1596
 
1597
        if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) {
1598
            $ext_opts    = ['SUBSCRIBED', 'CHILDREN'];
1599
            $rets        = array_intersect($return_opts, $ext_opts);
1600
            $return_opts = array_diff($return_opts, $rets);
1601
        }
1602
 
1603
        if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) {
1604
            $lstatus     = true;
1605
            $status_opts = ['MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', 'SIZE'];
1606
            $opts        = array_diff($return_opts, $status_opts);
1607
            $status_opts = array_diff($return_opts, $opts);
1608
 
1609
            if (!empty($status_opts)) {
1610
                $rets[] = 'STATUS (' . implode(' ', $status_opts) . ')';
1611
            }
1612
 
1613
            if (!empty($opts)) {
1614
                $rets = array_merge($rets, $opts);
1615
            }
1616
        }
1617
 
1618
        if (!empty($rets)) {
1619
            $args[] = 'RETURN (' . implode(' ', $rets) . ')';
1620
        }
1621
 
1622
        list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args);
1623
 
1624
        if ($code == self::ERROR_OK) {
1625
            $folders  = [];
1626
            $last     = 0;
1627
            $pos      = 0;
1628
            $response .= "\r\n";
1629
 
1630
            while ($pos = strpos($response, "\r\n", $pos+1)) {
1631
                // literal string, not real end-of-command-line
1632
                if ($response[$pos-1] == '}') {
1633
                    continue;
1634
                }
1635
 
1636
                $line = substr($response, $last, $pos - $last);
1637
                $last = $pos + 2;
1638
 
1639
                if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) {
1640
                    continue;
1641
                }
1642
 
1643
                $cmd  = strtoupper($m[1]);
1644
                $line = substr($line, strlen($m[0]));
1645
 
1646
                // * LIST (<options>) <delimiter> <mailbox>
1647
                if ($cmd == 'LIST' || $cmd == 'LSUB') {
1648
                    list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3);
1649
 
1650
                    // Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879)
1651
                    if ($delim) {
1652
                        $mailbox = rtrim($mailbox, $delim);
1653
                    }
1654
 
1655
                    // Make it easier for the client to deal with INBOX folder
1656
                    // by always returning the word with all capital letters
1657
                    if (strlen($mailbox) == 5
1658
                        && ($mailbox[0] == 'i' || $mailbox[0] == 'I')
1659
                        && ($mailbox[1] == 'n' || $mailbox[1] == 'N')
1660
                        && ($mailbox[2] == 'b' || $mailbox[2] == 'B')
1661
                        && ($mailbox[3] == 'o' || $mailbox[3] == 'O')
1662
                        && ($mailbox[4] == 'x' || $mailbox[4] == 'X')
1663
                    ) {
1664
                        $mailbox = 'INBOX';
1665
                    }
1666
 
1667
                    // Add to result array
1668
                    if (!$lstatus) {
1669
                        $folders[] = $mailbox;
1670
                    }
1671
                    else {
1672
                        $folders[$mailbox] = [];
1673
                    }
1674
 
1675
                    // store folder options
1676
                    if ($cmd == 'LIST') {
1677
                        // Add to options array
1678
                        if (empty($this->data['LIST'][$mailbox])) {
1679
                            $this->data['LIST'][$mailbox] = $opts;
1680
                        }
1681
                        else if (!empty($opts)) {
1682
                            $this->data['LIST'][$mailbox] = array_unique(array_merge(
1683
                                $this->data['LIST'][$mailbox], $opts));
1684
                        }
1685
                    }
1686
                }
1687
                else if ($lstatus) {
1688
                    // * STATUS <mailbox> (<result>)
1689
                    if ($cmd == 'STATUS') {
1690
                        list($mailbox, $status) = $this->tokenizeResponse($line, 2);
1691
 
1692
                        for ($i=0, $len=count($status); $i<$len; $i += 2) {
1693
                            list($name, $value) = $this->tokenizeResponse($status, 2);
1694
                            $folders[$mailbox][$name] = $value;
1695
                        }
1696
                    }
1697
                    // * MYRIGHTS <mailbox> <acl>
1698
                    else if ($cmd == 'MYRIGHTS') {
1699
                        list($mailbox, $acl)  = $this->tokenizeResponse($line, 2);
1700
                        $folders[$mailbox]['MYRIGHTS'] = $acl;
1701
                    }
1702
                }
1703
            }
1704
 
1705
            return $folders;
1706
        }
1707
 
1708
        return false;
1709
    }
1710
 
1711
    /**
1712
     * Returns count of all messages in a folder
1713
     *
1714
     * @param string $mailbox Mailbox name
1715
     *
1716
     * @return int Number of messages, False on error
1717
     */
1718
    public function countMessages($mailbox)
1719
    {
1720
        if ($this->selected === $mailbox && isset($this->data['EXISTS'])) {
1721
            return $this->data['EXISTS'];
1722
        }
1723
 
1724
        // Check internal cache
1725
        if (!empty($this->data['STATUS:'.$mailbox])) {
1726
            $cache = $this->data['STATUS:'.$mailbox];
1727
            if (isset($cache['MESSAGES'])) {
1728
                return (int) $cache['MESSAGES'];
1729
            }
1730
        }
1731
 
1732
        // Try STATUS (should be faster than SELECT)
1733
        $counts = $this->status($mailbox);
1734
        if (is_array($counts)) {
1735
            return (int) $counts['MESSAGES'];
1736
        }
1737
 
1738
        return false;
1739
    }
1740
 
1741
    /**
1742
     * Returns count of messages with \Recent flag in a folder
1743
     *
1744
     * @param string $mailbox Mailbox name
1745
     *
1746
     * @return int Number of messages, False on error
1747
     */
1748
    public function countRecent($mailbox)
1749
    {
1750
        if ($this->selected === $mailbox && isset($this->data['RECENT'])) {
1751
            return $this->data['RECENT'];
1752
        }
1753
 
1754
        // Check internal cache
1755
        $cache = $this->data['STATUS:'.$mailbox];
1756
        if (!empty($cache) && isset($cache['RECENT'])) {
1757
            return (int) $cache['RECENT'];
1758
        }
1759
 
1760
        // Try STATUS (should be faster than SELECT)
1761
        $counts = $this->status($mailbox, ['RECENT']);
1762
        if (is_array($counts)) {
1763
            return (int) $counts['RECENT'];
1764
        }
1765
 
1766
        return false;
1767
    }
1768
 
1769
    /**
1770
     * Returns count of messages without \Seen flag in a specified folder
1771
     *
1772
     * @param string $mailbox Mailbox name
1773
     *
1774
     * @return int Number of messages, False on error
1775
     */
1776
    public function countUnseen($mailbox)
1777
    {
1778
        // Check internal cache
1779
        if (!empty($this->data['STATUS:'.$mailbox])) {
1780
            $cache = $this->data['STATUS:'.$mailbox];
1781
            if (isset($cache['UNSEEN'])) {
1782
                return (int) $cache['UNSEEN'];
1783
            }
1784
        }
1785
 
1786
        // Try STATUS (should be faster than SELECT+SEARCH)
1787
        $counts = $this->status($mailbox);
1788
        if (is_array($counts)) {
1789
            return (int) $counts['UNSEEN'];
1790
        }
1791
 
1792
        // Invoke SEARCH as a fallback
1793
        $index = $this->search($mailbox, 'ALL UNSEEN', false, ['COUNT']);
1794
        if (!$index->is_error()) {
1795
            return $index->count();
1796
        }
1797
 
1798
        return false;
1799
    }
1800
 
1801
    /**
1802
     * Executes ID command (RFC2971)
1803
     *
1804
     * @param array $items Client identification information key/value hash
1805
     *
1806
     * @return array|false Server identification information key/value hash, False on error
1807
     * @since 0.6
1808
     */
1809
    public function id($items = [])
1810
    {
1811
        if (is_array($items) && !empty($items)) {
1812
            foreach ($items as $key => $value) {
1813
                $args[] = $this->escape($key, true);
1814
                $args[] = $this->escape($value, true);
1815
            }
1816
        }
1817
 
1818
        list($code, $response) = $this->execute('ID',
1819
            [!empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null)],
1820
            0, '/^\* ID /i'
1821
        );
1822
 
1823
        if ($code == self::ERROR_OK && $response) {
1824
            $response = substr($response, 5); // remove prefix "* ID "
1825
            $items    = $this->tokenizeResponse($response, 1);
1826
            $result   = [];
1827
 
1828
            if (is_array($items)) {
1829
                for ($i=0, $len=count($items); $i<$len; $i += 2) {
1830
                    $result[$items[$i]] = $items[$i+1];
1831
                }
1832
            }
1833
 
1834
            return $result;
1835
        }
1836
 
1837
        return false;
1838
    }
1839
 
1840
    /**
1841
     * Executes ENABLE command (RFC5161)
1842
     *
1843
     * @param mixed $extension Extension name to enable (or array of names)
1844
     *
1845
     * @return array|bool List of enabled extensions, False on error
1846
     * @since 0.6
1847
     */
1848
    public function enable($extension)
1849
    {
1850
        if (empty($extension)) {
1851
            return false;
1852
        }
1853
 
1854
        if (!$this->hasCapability('ENABLE')) {
1855
            return false;
1856
        }
1857
 
1858
        if (!is_array($extension)) {
1859
            $extension = [$extension];
1860
        }
1861
 
1862
        if (!empty($this->extensions_enabled)) {
1863
            // check if all extensions are already enabled
1864
            $diff = array_diff($extension, $this->extensions_enabled);
1865
 
1866
            if (empty($diff)) {
1867
                return $extension;
1868
            }
1869
 
1870
            // Make sure the mailbox isn't selected, before enabling extension(s)
1871
            if ($this->selected !== null) {
1872
                $this->close();
1873
            }
1874
        }
1875
 
1876
        list($code, $response) = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i');
1877
 
1878
        if ($code == self::ERROR_OK && $response) {
1879
            $response = substr($response, 10); // remove prefix "* ENABLED "
1880
            $result   = (array) $this->tokenizeResponse($response);
1881
 
1882
            $this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result));
1883
 
1884
            return $this->extensions_enabled;
1885
        }
1886
 
1887
        return false;
1888
    }
1889
 
1890
    /**
1891
     * Executes SORT command
1892
     *
1893
     * @param string $mailbox    Mailbox name
1894
     * @param string $field      Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
1895
     * @param string $criteria   Searching criteria
1896
     * @param bool   $return_uid Enables UID SORT usage
1897
     * @param string $encoding   Character set
1898
     *
1899
     * @return rcube_result_index Response data
1900
     */
1901
    public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
1902
    {
1903
        $old_sel   = $this->selected;
1904
        $supported = ['ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO'];
1905
        $field     = strtoupper($field);
1906
 
1907
        if ($field == 'INTERNALDATE') {
1908
            $field = 'ARRIVAL';
1909
        }
1910
 
1911
        if (!in_array($field, $supported)) {
1912
            return new rcube_result_index($mailbox);
1913
        }
1914
 
1915
        if (!$this->select($mailbox)) {
1916
            return new rcube_result_index($mailbox);
1917
        }
1918
 
1919
        // return empty result when folder is empty and we're just after SELECT
1920
        if ($old_sel != $mailbox && empty($this->data['EXISTS'])) {
1921
            return new rcube_result_index($mailbox, '* SORT');
1922
        }
1923
 
1924
        // RFC 5957: SORT=DISPLAY
1925
        if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) {
1926
            $field = 'DISPLAY' . $field;
1927
        }
1928
 
1929
        $encoding = $encoding ? trim($encoding) : 'US-ASCII';
1930
        $criteria = $criteria ? 'ALL ' . trim($criteria) : 'ALL';
1931
 
1932
        list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT',
1933
            ["($field)", $encoding, $criteria]);
1934
 
1935
        if ($code != self::ERROR_OK) {
1936
            $response = null;
1937
        }
1938
 
1939
        return new rcube_result_index($mailbox, $response);
1940
    }
1941
 
1942
    /**
1943
     * Executes THREAD command
1944
     *
1945
     * @param string $mailbox    Mailbox name
1946
     * @param string $algorithm  Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS)
1947
     * @param string $criteria   Searching criteria
1948
     * @param bool   $return_uid Enables UIDs in result instead of sequence numbers
1949
     * @param string $encoding   Character set
1950
     *
1951
     * @return rcube_result_thread Thread data
1952
     */
1953
    public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
1954
    {
1955
        $old_sel = $this->selected;
1956
 
1957
        if (!$this->select($mailbox)) {
1958
            return new rcube_result_thread($mailbox);
1959
        }
1960
 
1961
        // return empty result when folder is empty and we're just after SELECT
1962
        if ($old_sel != $mailbox && !$this->data['EXISTS']) {
1963
            return new rcube_result_thread($mailbox, '* THREAD');
1964
        }
1965
 
1966
        $encoding  = $encoding ? trim($encoding) : 'US-ASCII';
1967
        $algorithm = $algorithm ? trim($algorithm) : 'REFERENCES';
1968
        $criteria  = $criteria ? 'ALL '.trim($criteria) : 'ALL';
1969
 
1970
        list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD',
1971
            [$algorithm, $encoding, $criteria]);
1972
 
1973
        if ($code != self::ERROR_OK) {
1974
            $response = null;
1975
        }
1976
 
1977
        return new rcube_result_thread($mailbox, $response);
1978
    }
1979
 
1980
    /**
1981
     * Executes SEARCH command
1982
     *
1983
     * @param string $mailbox    Mailbox name
1984
     * @param string $criteria   Searching criteria
1985
     * @param bool   $return_uid Enable UID in result instead of sequence ID
1986
     * @param array  $items      Return items (MIN, MAX, COUNT, ALL)
1987
     *
1988
     * @return rcube_result_index Result data
1989
     */
1990
    public function search($mailbox, $criteria, $return_uid = false, $items = [])
1991
    {
1992
        $old_sel = $this->selected;
1993
 
1994
        if (!$this->select($mailbox)) {
1995
            return new rcube_result_index($mailbox);
1996
        }
1997
 
1998
        // return empty result when folder is empty and we're just after SELECT
1999
        if ($old_sel != $mailbox && !$this->data['EXISTS']) {
2000
            return new rcube_result_index($mailbox, '* SEARCH');
2001
        }
2002
 
2003
        // If ESEARCH is supported always use ALL
2004
        // but not when items are specified or using simple id2uid search
2005
        if (empty($items) && preg_match('/[^0-9]/', $criteria)) {
2006
            $items = ['ALL'];
2007
        }
2008
 
2009
        $esearch  = empty($items) ? false : $this->getCapability('ESEARCH');
2010
        $criteria = trim($criteria);
2011
        $params   = '';
2012
 
2013
        // RFC4731: ESEARCH
2014
        if (!empty($items) && $esearch) {
2015
            $params .= 'RETURN (' . implode(' ', $items) . ')';
2016
        }
2017
 
2018
        if (!empty($criteria)) {
2019
            $params .= ($params ? ' ' : '') . $criteria;
2020
        }
2021
        else {
2022
            $params .= 'ALL';
2023
        }
2024
 
2025
        list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH', [$params]);
2026
 
2027
        if ($code != self::ERROR_OK) {
2028
            $response = null;
2029
        }
2030
 
2031
        return new rcube_result_index($mailbox, $response);
2032
    }
2033
 
2034
    /**
2035
     * Simulates SORT command by using FETCH and sorting.
2036
     *
2037
     * @param string       $mailbox      Mailbox name
2038
     * @param string|array $message_set  Searching criteria (list of messages to return)
2039
     * @param string       $index_field  Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
2040
     * @param bool         $skip_deleted Makes that DELETED messages will be skipped
2041
     * @param bool         $uidfetch     Enables UID FETCH usage
2042
     * @param bool         $return_uid   Enables returning UIDs instead of IDs
2043
     *
2044
     * @return rcube_result_index Response data
2045
     */
2046
    public function index($mailbox, $message_set, $index_field = '', $skip_deleted = true,
2047
        $uidfetch = false, $return_uid = false)
2048
    {
2049
        $msg_index = $this->fetchHeaderIndex($mailbox, $message_set,
2050
            $index_field, $skip_deleted, $uidfetch, $return_uid);
2051
 
2052
        if (!empty($msg_index)) {
2053
            asort($msg_index); // ASC
2054
            $msg_index = array_keys($msg_index);
2055
            $msg_index = '* SEARCH ' . implode(' ', $msg_index);
2056
        }
2057
        else {
2058
            $msg_index = is_array($msg_index) ? '* SEARCH' : null;
2059
        }
2060
 
2061
        return new rcube_result_index($mailbox, $msg_index);
2062
    }
2063
 
2064
    /**
2065
     * Fetches specified header/data value for a set of messages.
2066
     *
2067
     * @param string       $mailbox      Mailbox name
2068
     * @param string|array $message_set  Searching criteria (list of messages to return)
2069
     * @param string       $index_field  Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
2070
     * @param bool         $skip_deleted Makes that DELETED messages will be skipped
2071
     * @param bool         $uidfetch     Enables UID FETCH usage
2072
     * @param bool         $return_uid   Enables returning UIDs instead of IDs
2073
     *
2074
     * @return array|bool List of header values or False on failure
2075
     */
2076
    public function fetchHeaderIndex($mailbox, $message_set, $index_field = '', $skip_deleted = true,
2077
        $uidfetch = false, $return_uid = false)
2078
    {
2079
        // Validate input
2080
        if (is_array($message_set)) {
2081
            if (!($message_set = $this->compressMessageSet($message_set))) {
2082
                return false;
2083
            }
2084
        }
2085
        else if (empty($message_set)) {
2086
            return false;
2087
        }
2088
        else if (strpos($message_set, ':')) {
2089
            list($from_idx, $to_idx) = explode(':', $message_set);
2090
            if ($to_idx != '*' && (int) $from_idx > (int) $to_idx) {
2091
                return false;
2092
            }
2093
        }
2094
 
2095
        $index_field = empty($index_field) ? 'DATE' : strtoupper($index_field);
2096
 
2097
        $supported = [
2098
            'DATE'         => 1,
2099
            'INTERNALDATE' => 4,
2100
            'ARRIVAL'      => 4,
2101
            'FROM'         => 1,
2102
            'REPLY-TO'     => 1,
2103
            'SENDER'       => 1,
2104
            'TO'           => 1,
2105
            'CC'           => 1,
2106
            'SUBJECT'      => 1,
2107
            'UID'          => 2,
2108
            'SIZE'         => 2,
2109
            'SEEN'         => 3,
2110
            'RECENT'       => 3,
2111
            'DELETED'      => 3,
2112
        ];
2113
 
2114
        if (empty($supported[$index_field])) {
2115
            return false;
2116
        }
2117
 
2118
        $mode = $supported[$index_field];
2119
 
2120
        //  Select the mailbox
2121
        if (!$this->select($mailbox)) {
2122
            return false;
2123
        }
2124
 
2125
        // build FETCH command string
2126
        $key    = $this->nextTag();
2127
        $cmd    = $uidfetch ? 'UID FETCH' : 'FETCH';
2128
        $fields = [];
2129
 
2130
        if ($return_uid) {
2131
            $fields[] = 'UID';
2132
        }
2133
        if ($skip_deleted) {
2134
            $fields[] = 'FLAGS';
2135
        }
2136
 
2137
        if ($mode == 1) {
2138
            if ($index_field == 'DATE') {
2139
                $fields[] = 'INTERNALDATE';
2140
            }
2141
            $fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]";
2142
        }
2143
        else if ($mode == 2) {
2144
            if ($index_field == 'SIZE') {
2145
                $fields[] = 'RFC822.SIZE';
2146
            }
2147
            else if (!$return_uid || $index_field != 'UID') {
2148
                $fields[] = $index_field;
2149
            }
2150
        }
2151
        else if ($mode == 3 && !$skip_deleted) {
2152
            $fields[] = 'FLAGS';
2153
        }
2154
        else if ($mode == 4) {
2155
            $fields[] = 'INTERNALDATE';
2156
        }
2157
 
2158
        $request = "$key $cmd $message_set (" . implode(' ', $fields) . ")";
2159
 
2160
        if (!$this->putLine($request)) {
2161
            $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2162
            return false;
2163
        }
2164
 
2165
        $result = [];
2166
 
2167
        do {
2168
            $line = rtrim($this->readLine(200));
2169
            $line = $this->multLine($line);
2170
 
2171
            if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
2172
                $id     = $m[1];
2173
                $flags  = null;
2174
 
2175
                if ($return_uid) {
2176
                    if (preg_match('/UID ([0-9]+)/', $line, $matches)) {
2177
                        $id = (int) $matches[1];
2178
                    }
2179
                    else {
2180
                        continue;
2181
                    }
2182
                }
2183
 
2184
                if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
2185
                    $flags = explode(' ', strtoupper($matches[1]));
2186
                    if (in_array('\\DELETED', $flags)) {
2187
                        continue;
2188
                    }
2189
                }
2190
 
2191
                if ($mode == 1 && $index_field == 'DATE') {
2192
                    if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) {
2193
                        $value = preg_replace(['/^"*[a-z]+:/i'], '', $matches[1]);
2194
                        $value = trim($value);
2195
                        $result[$id] = rcube_utils::strtotime($value);
2196
                    }
2197
                    // non-existent/empty Date: header, use INTERNALDATE
2198
                    if (empty($result[$id])) {
2199
                        if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
2200
                            $result[$id] = rcube_utils::strtotime($matches[1]);
2201
                        }
2202
                        else {
2203
                            $result[$id] = 0;
2204
                        }
2205
                    }
2206
                }
2207
                else if ($mode == 1) {
2208
                    if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) {
2209
                        $value = preg_replace(['/^"*[a-z]+:/i', '/\s+$/sm'], ['', ''], $matches[2]);
2210
                        $result[$id] = trim($value);
2211
                    }
2212
                    else {
2213
                        $result[$id] = '';
2214
                    }
2215
                }
2216
                else if ($mode == 2) {
2217
                    if (preg_match('/' . $index_field . ' ([0-9]+)/', $line, $matches)) {
2218
                        $result[$id] = trim($matches[1]);
2219
                    }
2220
                    else {
2221
                        $result[$id] = 0;
2222
                    }
2223
                }
2224
                else if ($mode == 3) {
2225
                    if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
2226
                        $flags = explode(' ', $matches[1]);
2227
                    }
2228
                    $result[$id] = in_array("\\".$index_field, (array) $flags) ? 1 : 0;
2229
                }
2230
                else if ($mode == 4) {
2231
                    if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
2232
                        $result[$id] = rcube_utils::strtotime($matches[1]);
2233
                    }
2234
                    else {
2235
                        $result[$id] = 0;
2236
                    }
2237
                }
2238
            }
2239
        }
2240
        while (!$this->startsWith($line, $key, true, true));
2241
 
2242
        return $result;
2243
    }
2244
 
2245
    /**
2246
     * Returns message sequence identifier
2247
     *
2248
     * @param string $mailbox Mailbox name
2249
     * @param int    $uid     Message unique identifier (UID)
2250
     *
2251
     * @return int Message sequence identifier
2252
     */
2253
    public function UID2ID($mailbox, $uid)
2254
    {
2255
        if ($uid > 0) {
2256
            $index = $this->search($mailbox, "UID $uid");
2257
 
2258
            if ($index->count() == 1) {
2259
                $arr = $index->get();
2260
                return (int) $arr[0];
2261
            }
2262
        }
2263
    }
2264
 
2265
    /**
2266
     * Returns message unique identifier (UID)
2267
     *
2268
     * @param string $mailbox Mailbox name
2269
     * @param int    $id      Message sequence identifier
2270
     *
2271
     * @return int Message unique identifier
2272
     */
2273
    public function ID2UID($mailbox, $id)
2274
    {
2275
        if (empty($id) || $id < 0) {
2276
            return null;
2277
        }
2278
 
2279
        if (!$this->select($mailbox)) {
2280
            return null;
2281
        }
2282
 
2283
        if (!empty($this->data['UID-MAP'][$id])) {
2284
            return $this->data['UID-MAP'][$id];
2285
        }
2286
 
2287
        if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) {
2288
            return null;
2289
        }
2290
 
2291
        $index = $this->search($mailbox, $id, true);
2292
 
2293
        if ($index->count() == 1) {
2294
            $arr = $index->get();
2295
            return $this->data['UID-MAP'][$id] = (int) $arr[0];
2296
        }
2297
    }
2298
 
2299
    /**
2300
     * Sets flag of the message(s)
2301
     *
2302
     * @param string       $mailbox  Mailbox name
2303
     * @param string|array $messages Message UID(s)
2304
     * @param string       $flag     Flag name
2305
     *
2306
     * @return bool True on success, False on failure
2307
     */
2308
    public function flag($mailbox, $messages, $flag)
2309
    {
2310
        return $this->modFlag($mailbox, $messages, $flag, '+');
2311
    }
2312
 
2313
    /**
2314
     * Unsets flag of the message(s)
2315
     *
2316
     * @param string       $mailbox  Mailbox name
2317
     * @param string|array $messages Message UID(s)
2318
     * @param string       $flag     Flag name
2319
     *
2320
     * @return bool True on success, False on failure
2321
     */
2322
    public function unflag($mailbox, $messages, $flag)
2323
    {
2324
        return $this->modFlag($mailbox, $messages, $flag, '-');
2325
    }
2326
 
2327
    /**
2328
     * Changes flag of the message(s)
2329
     *
2330
     * @param string       $mailbox  Mailbox name
2331
     * @param string|array $messages Message UID(s)
2332
     * @param string       $flag     Flag name
2333
     * @param string       $mod      Modifier [+|-]. Default: "+".
2334
     *
2335
     * @return bool True on success, False on failure
2336
     */
2337
    protected function modFlag($mailbox, $messages, $flag, $mod = '+')
2338
    {
2339
        if (!$flag) {
2340
            return false;
2341
        }
2342
 
2343
        if (!$this->select($mailbox)) {
2344
            return false;
2345
        }
2346
 
2347
        if (empty($this->data['READ-WRITE'])) {
2348
            $this->setError(self::ERROR_READONLY, "Mailbox is read-only");
2349
            return false;
2350
        }
2351
 
2352
        if (!empty($this->flags[strtoupper($flag)])) {
2353
            $flag = $this->flags[strtoupper($flag)];
2354
        }
2355
 
2356
        // if PERMANENTFLAGS is not specified all flags are allowed
2357
        if (!empty($this->data['PERMANENTFLAGS'])
2358
            && !in_array($flag, (array) $this->data['PERMANENTFLAGS'])
2359
            && !in_array('\\*', (array) $this->data['PERMANENTFLAGS'])
2360
        ) {
2361
            return false;
2362
        }
2363
 
2364
        // Clear internal status cache
2365
        if ($flag == 'SEEN') {
2366
            unset($this->data['STATUS:'.$mailbox]['UNSEEN']);
2367
        }
2368
 
2369
        if ($mod != '+' && $mod != '-') {
2370
            $mod = '+';
2371
        }
2372
 
2373
        $result = $this->execute('UID STORE',
2374
            [$this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"],
2375
            self::COMMAND_NORESPONSE
2376
        );
2377
 
2378
        return $result == self::ERROR_OK;
2379
    }
2380
 
2381
    /**
2382
     * Copies message(s) from one folder to another
2383
     *
2384
     * @param string|array $messages Message UID(s)
2385
     * @param string       $from     Mailbox name
2386
     * @param string       $to       Destination mailbox name
2387
     *
2388
     * @return bool True on success, False on failure
2389
     */
2390
    public function copy($messages, $from, $to)
2391
    {
2392
        // Clear last COPYUID data
2393
        unset($this->data['COPYUID']);
2394
 
2395
        if (!$this->select($from)) {
2396
            return false;
2397
        }
2398
 
2399
        // Clear internal status cache
2400
        unset($this->data['STATUS:'.$to]);
2401
 
2402
        $result = $this->execute('UID COPY',
2403
            [$this->compressMessageSet($messages), $this->escape($to)],
2404
            self::COMMAND_NORESPONSE
2405
        );
2406
 
2407
        return $result == self::ERROR_OK;
2408
    }
2409
 
2410
    /**
2411
     * Moves message(s) from one folder to another.
2412
     *
2413
     * @param string|array $messages Message UID(s)
2414
     * @param string       $from     Mailbox name
2415
     * @param string       $to       Destination mailbox name
2416
     *
2417
     * @return bool True on success, False on failure
2418
     */
2419
    public function move($messages, $from, $to)
2420
    {
2421
        if (!$this->select($from)) {
2422
            return false;
2423
        }
2424
 
2425
        if (empty($this->data['READ-WRITE'])) {
2426
            $this->setError(self::ERROR_READONLY, "Mailbox is read-only");
2427
            return false;
2428
        }
2429
 
2430
        // use MOVE command (RFC 6851)
2431
        if ($this->hasCapability('MOVE')) {
2432
            // Clear last COPYUID data
2433
            unset($this->data['COPYUID']);
2434
 
2435
            // Clear internal status cache
2436
            unset($this->data['STATUS:'.$to]);
2437
            $this->clear_status_cache($from);
2438
 
2439
            $result = $this->execute('UID MOVE',
2440
                [$this->compressMessageSet($messages), $this->escape($to)],
2441
                self::COMMAND_NORESPONSE
2442
            );
2443
 
2444
            return $result == self::ERROR_OK;
2445
        }
2446
 
2447
        // use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE
2448
        $result = $this->copy($messages, $from, $to);
2449
 
2450
        if ($result) {
2451
            // Clear internal status cache
2452
            unset($this->data['STATUS:'.$from]);
2453
 
2454
            $result = $this->flag($from, $messages, 'DELETED');
2455
 
2456
            if ($messages == '*') {
2457
                // CLOSE+SELECT should be faster than EXPUNGE
2458
                $this->close();
2459
            }
2460
            else {
2461
                $this->expunge($from, $messages);
2462
            }
2463
        }
2464
 
2465
        return $result;
2466
    }
2467
 
2468
    /**
2469
     * FETCH command (RFC3501)
2470
     *
2471
     * @param string $mailbox     Mailbox name
2472
     * @param mixed  $message_set Message(s) sequence identifier(s) or UID(s)
2473
     * @param bool   $is_uid      True if $message_set contains UIDs
2474
     * @param array  $query_items FETCH command data items
2475
     * @param string $mod_seq     Modification sequence for CHANGEDSINCE (RFC4551) query
2476
     * @param bool   $vanished    Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query
2477
     *
2478
     * @return array List of rcube_message_header elements, False on error
2479
     * @since 0.6
2480
     */
2481
    public function fetch($mailbox, $message_set, $is_uid = false, $query_items = [],
2482
        $mod_seq = null, $vanished = false)
2483
    {
2484
        if (!$this->select($mailbox)) {
2485
            return false;
2486
        }
2487
 
2488
        $message_set = $this->compressMessageSet($message_set);
2489
        $result      = [];
2490
 
2491
        $key      = $this->nextTag();
2492
        $cmd      = ($is_uid ? 'UID ' : '') . 'FETCH';
2493
        $request  = "$key $cmd $message_set (" . implode(' ', $query_items) . ")";
2494
 
2495
        if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) {
2496
            $request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")";
2497
        }
2498
 
2499
        if (!$this->putLine($request)) {
2500
            $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2501
            return false;
2502
        }
2503
 
2504
        do {
2505
            $line = $this->readFullLine(4096);
2506
 
2507
            if (!$line) {
2508
                break;
2509
            }
2510
 
2511
            // Sample reply line:
2512
            // * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen)
2513
            // INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...)
2514
            // BODY[HEADER.FIELDS ...
2515
 
2516
            if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
2517
                $id = intval($m[1]);
2518
 
2519
                $result[$id]            = new rcube_message_header;
2520
                $result[$id]->id        = $id;
2521
                $result[$id]->subject   = '';
2522
                $result[$id]->messageID = 'mid:' . $id;
2523
 
2524
                $headers = null;
2525
                $line    = substr($line, strlen($m[0]) + 2);
2526
 
2527
                // Tokenize response and assign to object properties
2528
                while (($tokens = $this->tokenizeResponse($line, 2)) && count($tokens) == 2) {
2529
                    list($name, $value) = $tokens;
2530
                    if ($name == 'UID') {
2531
                        $result[$id]->uid = intval($value);
2532
                    }
2533
                    else if ($name == 'RFC822.SIZE') {
2534
                        $result[$id]->size = intval($value);
2535
                    }
2536
                    else if ($name == 'RFC822.TEXT') {
2537
                        $result[$id]->body = $value;
2538
                    }
2539
                    else if ($name == 'INTERNALDATE') {
2540
                        $result[$id]->internaldate = $value;
2541
                        $result[$id]->date         = $value;
2542
                        $result[$id]->timestamp    = rcube_utils::strtotime($value);
2543
                    }
2544
                    else if ($name == 'FLAGS') {
2545
                        if (!empty($value)) {
2546
                            foreach ((array)$value as $flag) {
2547
                                $flag = str_replace(['$', "\\"], '', $flag);
2548
                                $flag = strtoupper($flag);
2549
 
2550
                                $result[$id]->flags[$flag] = true;
2551
                            }
2552
                        }
2553
                    }
2554
                    else if ($name == 'MODSEQ') {
2555
                        $result[$id]->modseq = $value[0];
2556
                    }
2557
                    else if ($name == 'ENVELOPE') {
2558
                        $result[$id]->envelope = $value;
2559
                    }
2560
                    else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) {
2561
                        if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) {
2562
                            $value = [$value];
2563
                        }
2564
                        $result[$id]->bodystructure = $value;
2565
                    }
2566
                    else if ($name == 'RFC822') {
2567
                        $result[$id]->body = $value;
2568
                    }
2569
                    else if (stripos($name, 'BODY[') === 0) {
2570
                        $name = str_replace(']', '', substr($name, 5));
2571
 
2572
                        if ($name == 'HEADER.FIELDS') {
2573
                            // skip ']' after headers list
2574
                            $this->tokenizeResponse($line, 1);
2575
                            $headers = $this->tokenizeResponse($line, 1);
2576
                        }
2577
                        else if (strlen($name)) {
2578
                            $result[$id]->bodypart[$name] = $value;
2579
                        }
2580
                        else {
2581
                            $result[$id]->body = $value;
2582
                        }
2583
                    }
2584
                }
2585
 
2586
                // create array with header field:data
2587
                if (!empty($headers)) {
2588
                    $headers = explode("\n", trim($headers));
2589
                    $lines   = [];
2590
                    $ln      = 0;
2591
 
2592
                    foreach ($headers as $resln) {
2593
                        if (!isset($resln[0]) || ord($resln[0]) <= 32) {
2594
                            $lines[$ln] = ($lines[$ln] ?? '') . (empty($lines[$ln]) ? '' : "\n") . trim($resln);
2595
                        }
2596
                        else {
2597
                            $lines[++$ln] = trim($resln);
2598
                        }
2599
                    }
2600
 
2601
                    foreach ($lines as $str) {
2602
                        if (strpos($str, ':') === false) {
2603
                            continue;
2604
                        }
2605
 
2606
                        list($field, $string) = explode(':', $str, 2);
2607
 
2608
                        $field  = strtolower($field);
2609
                        $string = preg_replace('/\n[\t\s]*/', ' ', trim($string));
2610
 
2611
                        switch ($field) {
2612
                        case 'date';
2613
                            $string                 = substr($string, 0, 128);
2614
                            $result[$id]->date      = $string;
2615
                            $result[$id]->timestamp = rcube_utils::strtotime($string);
2616
                            break;
2617
                        case 'to':
2618
                            $result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string);
2619
                            break;
2620
                        case 'from':
2621
                        case 'subject':
2622
                            $string = substr($string, 0, 2048);
2623
                        case 'cc':
2624
                        case 'bcc':
2625
                        case 'references':
2626
                            $result[$id]->{$field} = $string;
2627
                            break;
2628
                        case 'reply-to':
2629
                            $result[$id]->replyto = $string;
2630
                            break;
2631
                        case 'content-transfer-encoding':
2632
                            $result[$id]->encoding = substr($string, 0, 32);
2633
                        break;
2634
                        case 'content-type':
2635
                            $ctype_parts = preg_split('/[; ]+/', $string);
2636
                            $result[$id]->ctype = strtolower(array_shift($ctype_parts));
2637
                            if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) {
2638
                                $result[$id]->charset = $regs[1];
2639
                            }
2640
                            break;
2641
                        case 'in-reply-to':
2642
                            $result[$id]->in_reply_to = str_replace(["\n", '<', '>'], '', $string);
2643
                            break;
2644
                        case 'disposition-notification-to':
2645
                        case 'x-confirm-reading-to':
2646
                            $result[$id]->mdn_to = substr($string, 0, 2048);
2647
                            break;
2648
                        case 'message-id':
2649
                            $result[$id]->messageID = substr($string, 0, 2048);
2650
                            break;
2651
                        case 'x-priority':
2652
                            if (preg_match('/^(\d+)/', $string, $matches)) {
2653
                                $result[$id]->priority = intval($matches[1]);
2654
                            }
2655
                            break;
2656
                        default:
2657
                            if (strlen($field) < 3) {
2658
                                break;
2659
                            }
2660
                            if (!empty($result[$id]->others[$field])) {
2661
                                $string = array_merge((array) $result[$id]->others[$field], (array) $string);
2662
                            }
2663
                            $result[$id]->others[$field] = $string;
2664
                        }
2665
                    }
2666
                }
2667
            }
2668
            // VANISHED response (QRESYNC RFC5162)
2669
            // Sample: * VANISHED (EARLIER) 300:310,405,411
2670
            else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
2671
                $line   = substr($line, strlen($match[0]));
2672
                $v_data = $this->tokenizeResponse($line, 1);
2673
 
2674
                $this->data['VANISHED'] = $v_data;
2675
            }
2676
        }
2677
        while (!$this->startsWith($line, $key, true));
2678
 
2679
        return $result;
2680
    }
2681
 
2682
    /**
2683
     * Returns message(s) data (flags, headers, etc.)
2684
     *
2685
     * @param string $mailbox     Mailbox name
2686
     * @param mixed  $message_set Message(s) sequence identifier(s) or UID(s)
2687
     * @param bool   $is_uid      True if $message_set contains UIDs
2688
     * @param bool   $bodystr     Enable to add BODYSTRUCTURE data to the result
2689
     * @param array  $add_headers List of additional headers
2690
     *
2691
     * @return bool|array List of rcube_message_header elements, False on error
2692
     */
2693
    public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = [])
2694
    {
2695
        $query_items = ['UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE'];
2696
        $headers     = ['DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO',
2697
            'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY'];
2698
 
2699
        if (!empty($add_headers)) {
2700
            $add_headers = array_map('strtoupper', $add_headers);
2701
            $headers     = array_unique(array_merge($headers, $add_headers));
2702
        }
2703
 
2704
        if ($bodystr) {
2705
            $query_items[] = 'BODYSTRUCTURE';
2706
        }
2707
 
2708
        $query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]';
2709
 
2710
        return $this->fetch($mailbox, $message_set, $is_uid, $query_items);
2711
    }
2712
 
2713
    /**
2714
     * Returns message data (flags, headers, etc.)
2715
     *
2716
     * @param string $mailbox     Mailbox name
2717
     * @param int    $id          Message sequence identifier or UID
2718
     * @param bool   $is_uid      True if $id is an UID
2719
     * @param bool   $bodystr     Enable to add BODYSTRUCTURE data to the result
2720
     * @param array  $add_headers List of additional headers
2721
     *
2722
     * @return bool|rcube_message_header Message data, False on error
2723
     */
2724
    public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = [])
2725
    {
2726
        $a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers);
2727
 
2728
        if (is_array($a)) {
2729
            return array_shift($a);
2730
        }
2731
 
2732
        return false;
2733
    }
2734
 
2735
    /**
2736
     * Sort messages by specified header field
2737
     *
2738
     * @param array  $messages Array of rcube_message_header objects
2739
     * @param string $field    Name of the property to sort by
2740
     * @param string $order    Sorting order (ASC|DESC)
2741
     *
2742
     * @return array Sorted input array
2743
     */
2744
    public static function sortHeaders($messages, $field, $order = 'ASC')
2745
    {
2746
        $field = empty($field) ? 'uid' : strtolower($field);
2747
        $order = empty($order) ? 'ASC' : strtoupper($order);
2748
        $index = [];
2749
 
2750
        reset($messages);
2751
 
2752
        // Create an index
2753
        foreach ($messages as $key => $headers) {
2754
            switch ($field) {
2755
            case 'arrival':
2756
                $field = 'internaldate';
2757
                // no-break
2758
            case 'date':
2759
            case 'internaldate':
2760
            case 'timestamp':
2761
                $value = rcube_utils::strtotime($headers->$field);
2762
                if (!$value && $field != 'timestamp') {
2763
                    $value = $headers->timestamp;
2764
                }
2765
 
2766
                break;
2767
 
2768
            default:
2769
                // @TODO: decode header value, convert to UTF-8
2770
                $value = $headers->$field;
2771
                if (is_string($value)) {
2772
                    $value = str_replace('"', '', $value);
2773
 
2774
                    if ($field == 'subject') {
2775
                        $value = rcube_utils::remove_subject_prefix($value);
2776
                    }
2777
                }
2778
            }
2779
 
2780
            $index[$key] = $value;
2781
        }
2782
 
2783
        $sort_order = $order == 'ASC' ? SORT_ASC : SORT_DESC;
2784
        $sort_flags = SORT_STRING | SORT_FLAG_CASE;
2785
 
2786
        if (in_array($field, ['arrival', 'date', 'internaldate', 'timestamp', 'size', 'uid', 'id'])) {
2787
            $sort_flags = SORT_NUMERIC;
2788
        }
2789
 
2790
        array_multisort($index, $sort_order, $sort_flags, $messages);
2791
 
2792
        return $messages;
2793
    }
2794
 
2795
    /**
2796
     * Fetch MIME headers of specified message parts
2797
     *
2798
     * @param string $mailbox Mailbox name
2799
     * @param int    $uid     Message UID
2800
     * @param array  $parts   Message part identifiers
2801
     * @param bool   $mime    Use MIME instead of HEADER
2802
     *
2803
     * @return array|bool Array containing headers string for each specified body
2804
     *                    False on failure.
2805
     */
2806
    public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true)
2807
    {
2808
        if (!$this->select($mailbox)) {
2809
            return false;
2810
        }
2811
 
2812
        $parts  = (array) $parts;
2813
        $key    = $this->nextTag();
2814
        $peeks  = [];
2815
        $type   = $mime ? 'MIME' : 'HEADER';
2816
 
2817
        // format request
2818
        foreach ($parts as $part) {
2819
            $peeks[] = "BODY.PEEK[$part.$type]";
2820
        }
2821
 
2822
        $request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')';
2823
 
2824
        // send request
2825
        if (!$this->putLine($request)) {
2826
            $this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command");
2827
            return false;
2828
        }
2829
 
2830
        $result = [];
2831
 
2832
        do {
2833
            $line = $this->readLine(1024);
2834
            if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) {
2835
                $line = ltrim(substr($line, strlen($m[0])));
2836
                while (preg_match('/^\s*BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) {
2837
                    $line = substr($line, strlen($matches[0]));
2838
                    $result[$matches[1]] = trim($this->multLine($line));
2839
                    $line = $this->readLine(1024);
2840
                }
2841
            }
2842
        }
2843
        while (!$this->startsWith($line, $key, true));
2844
 
2845
        return $result;
2846
    }
2847
 
2848
    /**
2849
     * Fetches message part header
2850
     */
2851
    public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null)
2852
    {
2853
        $part = empty($part) ? 'HEADER' : $part.'.MIME';
2854
 
2855
        return $this->handlePartBody($mailbox, $id, $is_uid, $part);
2856
    }
2857
 
2858
    /**
2859
     * Fetches body of the specified message part
2860
     */
2861
    public function handlePartBody($mailbox, $id, $is_uid = false, $part = '', $encoding = null, $print = null,
2862
        $file = null, $formatted = false, $max_bytes = 0)
2863
    {
2864
        if (!$this->select($mailbox)) {
2865
            return false;
2866
        }
2867
 
2868
        $binary    = true;
2869
        $initiated = false;
2870
 
2871
        do {
2872
            if (!$initiated) {
2873
                switch ($encoding) {
2874
                case 'base64':
2875
                    $mode = 1;
2876
                    break;
2877
                case 'quoted-printable':
2878
                    $mode = 2;
2879
                    break;
2880
                case 'x-uuencode':
2881
                case 'x-uue':
2882
                case 'uue':
2883
                case 'uuencode':
2884
                    $mode = 3;
2885
                    break;
2886
                default:
2887
                    $mode = $formatted ? 4 : 0;
2888
                }
2889
 
2890
                // Use BINARY extension when possible (and safe)
2891
                $binary     = $binary && $mode && preg_match('/^[0-9.]+$/', (string) $part) && $this->hasCapability('BINARY');
2892
                $fetch_mode = $binary ? 'BINARY' : 'BODY';
2893
                $partial    = $max_bytes ? sprintf('<0.%d>', $max_bytes) : '';
2894
 
2895
                // format request
2896
                $key       = $this->nextTag();
2897
                $cmd       = ($is_uid ? 'UID ' : '') . 'FETCH';
2898
                $request   = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)";
2899
                $result    = false;
2900
                $found     = false;
2901
                $initiated = true;
2902
 
2903
                // send request
2904
                if (!$this->putLine($request)) {
2905
                    $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2906
                    return false;
2907
                }
2908
 
2909
                if ($binary) {
2910
                    // WARNING: Use $formatted argument with care, this may break binary data stream
2911
                    $mode = -1;
2912
                }
2913
            }
2914
 
2915
            $line = trim($this->readLine(1024));
2916
 
2917
            if (!$line) {
2918
                break;
2919
            }
2920
 
2921
            // handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request
2922
            if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) {
2923
                $binary = $initiated = false;
2924
                continue;
2925
            }
2926
 
2927
            // skip irrelevant untagged responses (we have a result already)
2928
            if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) {
2929
                continue;
2930
            }
2931
 
2932
            $line = $m[2];
2933
 
2934
            // handle one line response
2935
            if ($line[0] == '(' && substr($line, -1) == ')') {
2936
                // tokenize content inside brackets
2937
                // the content can be e.g.: (UID 9844 BODY[2.4] NIL)
2938
                $line = preg_replace('/(^\(|\)$)/', '', $line);
2939
                $tokens = $this->tokenizeResponse($line);
2940
 
2941
                for ($i=0; $i<count($tokens); $i+=2) {
2942
                    if (preg_match('/^(BODY|BINARY)/i', $tokens[$i])) {
2943
                        $result = $tokens[$i+1];
2944
                        $found  = true;
2945
                        break;
2946
                    }
2947
                }
2948
 
2949
                // Cyrus IMAP does not return a NO-response on error, but we can detect it
2950
                // and fallback to a non-binary fetch (#9097)
2951
                if ($binary && !$found) {
2952
                    $binary = $initiated = false;
2953
                    $line = trim($this->readLine(1024)); // the OK response line
2954
                    continue;
2955
                }
2956
 
2957
                if ($result !== false) {
2958
                    $result = $this->decodeContent($result, $mode, true);
2959
                }
2960
            }
2961
            // response with string literal
2962
            else if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
2963
                $bytes = (int) $m[1];
2964
                $prev  = '';
2965
                $found = true;
2966
                $chunkSize = 1024 * 1024;
2967
 
2968
                // empty body
2969
                if (!$bytes) {
2970
                    $result = '';
2971
                }
2972
                // An optimal path for a case when we need the body as-is in a string
2973
                else if (!$mode && !$file && !$print) {
2974
                    $result = $this->readBytes($bytes);
2975
                }
2976
                else while ($bytes > 0) {
2977
                    $chunk = $this->readBytes($bytes > $chunkSize ? $chunkSize : $bytes);
2978
 
2979
                    if ($chunk === '') {
2980
                        break;
2981
                    }
2982
 
2983
                    $len = strlen($chunk);
2984
 
2985
                    if ($len > $bytes) {
2986
                        $chunk = substr($chunk, 0, $bytes);
2987
                        $len = strlen($chunk);
2988
                    }
2989
                    $bytes -= $len;
2990
 
2991
                    $chunk = $this->decodeContent($chunk, $mode, $bytes <= 0, $prev);
2992
 
2993
                    if ($file) {
2994
                        if (fwrite($file, $chunk) === false) {
2995
                            break;
2996
                        }
2997
                    }
2998
                    else if ($print) {
2999
                        echo $chunk;
3000
                    }
3001
                    else {
3002
                        $result .= $chunk;
3003
                    }
3004
                }
3005
            }
3006
        }
3007
        while (!$this->startsWith($line, $key, true) || !$initiated);
3008
 
3009
        if ($result !== false) {
3010
            if ($file) {
3011
                return fwrite($file, $result);
3012
            }
3013
            else if ($print) {
3014
                echo $result;
3015
                return true;
3016
            }
3017
 
3018
            return $result;
3019
        }
3020
 
3021
        return false;
3022
    }
3023
 
3024
    /**
3025
     * Decodes a chunk of a message part content from a FETCH response.
3026
     *
3027
     * @param string $chunk   Content
3028
     * @param int    $mode    Encoding mode
3029
     * @param bool   $is_last Whether it is a last chunk of data
3030
     * @param string $prev    Extra content from the previous chunk
3031
     *
3032
     * @return string Encoded string
3033
     */
3034
    protected static function decodeContent($chunk, $mode, $is_last = false, &$prev = '')
3035
    {
3036
        // BASE64
3037
        if ($mode == 1) {
3038
            $chunk = $prev . preg_replace('|[^a-zA-Z0-9+=/]|', '', $chunk);
3039
 
3040
            // create chunks with proper length for base64 decoding
3041
            $length = strlen($chunk);
3042
 
3043
            if ($length % 4) {
3044
                $length = floor($length / 4) * 4;
3045
                $prev = substr($chunk, $length);
3046
                $chunk = substr($chunk, 0, $length);
3047
            }
3048
            else {
3049
                $prev = '';
3050
            }
3051
 
3052
            return base64_decode($chunk);
3053
        }
3054
 
3055
        // QUOTED-PRINTABLE
3056
        if ($mode == 2) {
3057
            if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
3058
                return '';
3059
            }
3060
 
3061
            $chunk = preg_replace('/[\t\r\0\x0B]+\n/', "\n", $chunk);
3062
 
3063
            return quoted_printable_decode($chunk);
3064
        }
3065
 
3066
        // X-UUENCODE
3067
        if ($mode == 3) {
3068
            if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
3069
                return '';
3070
            }
3071
 
3072
            $chunk = preg_replace(
3073
                ['/\r?\n/', '/(^|\n)end$/', '/^begin\s+[0-7]{3,4}\s+[^\n]+\n/'],
3074
                ["\n", '', ''],
3075
                $chunk
3076
            );
3077
 
3078
            if (!strlen($chunk)) {
3079
                return '';
3080
            }
3081
 
3082
            return convert_uudecode($chunk);
3083
        }
3084
 
3085
        // Plain text formatted
3086
        // TODO: Formatting should be handled outside of this class
3087
        if ($mode == 4) {
3088
            if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
3089
                return '';
3090
            }
3091
 
3092
            if ($is_last) {
3093
                $chunk = rtrim($chunk, "\t\r\n\0\x0B");
3094
            }
3095
 
3096
            return preg_replace('/[\t\r\0\x0B]+\n/', "\n", $chunk);
3097
        }
3098
 
3099
        return $chunk;
3100
    }
3101
 
3102
    /**
3103
     * A helper for a new-line aware parsing. See self::decodeContent().
3104
     */
3105
    private static function decodeContentChunk(&$chunk, &$prev, $is_last)
3106
    {
3107
        $chunk = $prev . $chunk;
3108
        $prev = '';
3109
 
3110
        if (!$is_last) {
3111
            if (($pos = strrpos($chunk, "\n")) !== false) {
3112
                $prev = substr($chunk, $pos + 1);
3113
                $chunk = substr($chunk, 0, $pos + 1);
3114
            } else {
3115
                $prev = $chunk;
3116
                return false;
3117
            }
3118
        }
3119
 
3120
        return true;
3121
    }
3122
 
3123
    /**
3124
     * Handler for IMAP APPEND command
3125
     *
3126
     * @param string       $mailbox Mailbox name
3127
     * @param string|array $message The message source string or array (of strings and file pointers)
3128
     * @param array        $flags   Message flags
3129
     * @param string       $date    Message internal date
3130
     * @param bool         $binary  Enable BINARY append (RFC3516)
3131
     *
3132
     * @return string|bool On success APPENDUID response (if available) or True, False on failure
3133
     */
3134
    public function append($mailbox, &$message, $flags = [], $date = null, $binary = false)
3135
    {
3136
        unset($this->data['APPENDUID']);
3137
 
3138
        if ($mailbox === null || $mailbox === '') {
3139
            return false;
3140
        }
3141
 
3142
        $binary       = $binary && $this->getCapability('BINARY');
3143
        $literal_plus = !$binary && !empty($this->prefs['literal+']);
3144
        $len          = 0;
3145
        $msg          = is_array($message) ? $message : [&$message];
3146
        $chunk_size   = 512000;
3147
 
3148
        for ($i=0, $cnt=count($msg); $i<$cnt; $i++) {
3149
            if (is_resource($msg[$i])) {
3150
                $stat = fstat($msg[$i]);
3151
                if ($stat === false) {
3152
                    return false;
3153
                }
3154
                $len += $stat['size'];
3155
            }
3156
            else {
3157
                if (!$binary) {
3158
                    $msg[$i] = str_replace("\r", '', $msg[$i]);
3159
                    $msg[$i] = str_replace("\n", "\r\n", $msg[$i]);
3160
                }
3161
 
3162
                $len += strlen($msg[$i]);
3163
            }
3164
        }
3165
 
3166
        if (!$len) {
3167
            return false;
3168
        }
3169
 
3170
        // build APPEND command
3171
        $key = $this->nextTag();
3172
        $request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')';
3173
        if (!empty($date)) {
3174
            $request .= ' ' . $this->escape($date);
3175
        }
3176
        $request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}';
3177
 
3178
        // send APPEND command
3179
        if (!$this->putLine($request)) {
3180
            $this->setError(self::ERROR_COMMAND, "Failed to send APPEND command");
3181
            return false;
3182
        }
3183
 
3184
        // Do not wait when LITERAL+ is supported
3185
        if (!$literal_plus) {
3186
            $line = $this->readReply();
3187
 
3188
            if ($line[0] != '+') {
3189
                $this->parseResult($line, 'APPEND: ');
3190
                return false;
3191
            }
3192
        }
3193
 
3194
        foreach ($msg as $msg_part) {
3195
            // file pointer
3196
            if (is_resource($msg_part)) {
3197
                rewind($msg_part);
3198
                while (!feof($msg_part) && $this->fp) {
3199
                    $buffer = fread($msg_part, $chunk_size);
3200
                    $this->putLine($buffer, false);
3201
                }
3202
                fclose($msg_part);
3203
            }
3204
            // string
3205
            else {
3206
                $size = strlen($msg_part);
3207
 
3208
                // Break up the data by sending one chunk (up to 512k) at a time.
3209
                // This approach reduces our peak memory usage
3210
                for ($offset = 0; $offset < $size; $offset += $chunk_size) {
3211
                    $chunk = substr($msg_part, $offset, $chunk_size);
3212
                    if (!$this->putLine($chunk, false)) {
3213
                        return false;
3214
                    }
3215
                }
3216
            }
3217
        }
3218
 
3219
        if (!$this->putLine('')) { // \r\n
3220
            return false;
3221
        }
3222
 
3223
        do {
3224
            $line = $this->readLine();
3225
        } while (!$this->startsWith($line, $key, true, true));
3226
 
3227
        // Clear internal status cache
3228
        unset($this->data['STATUS:'.$mailbox]);
3229
 
3230
        if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) {
3231
            return false;
3232
        }
3233
 
3234
        if (!empty($this->data['APPENDUID'])) {
3235
            return $this->data['APPENDUID'];
3236
        }
3237
 
3238
        return true;
3239
    }
3240
 
3241
    /**
3242
     * Handler for IMAP APPEND command.
3243
     *
3244
     * @param string $mailbox Mailbox name
3245
     * @param string $path    Path to the file with message body
3246
     * @param string $headers Message headers
3247
     * @param array  $flags   Message flags
3248
     * @param string $date    Message internal date
3249
     * @param bool   $binary  Enable BINARY append (RFC3516)
3250
     *
3251
     * @return string|bool On success APPENDUID response (if available) or True, False on failure
3252
     */
3253
    public function appendFromFile($mailbox, $path, $headers = null, $flags = [], $date = null, $binary = false)
3254
    {
3255
        // open message file
3256
        if (file_exists(realpath($path))) {
3257
            $fp = fopen($path, 'r');
3258
        }
3259
 
3260
        if (empty($fp)) {
3261
            $this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading");
3262
            return false;
3263
        }
3264
 
3265
        $message = [];
3266
        if ($headers) {
3267
            $message[] = trim($headers, "\r\n") . "\r\n\r\n";
3268
        }
3269
        $message[] = $fp;
3270
 
3271
        return $this->append($mailbox, $message, $flags, $date, $binary);
3272
    }
3273
 
3274
    /**
3275
     * Returns QUOTA information
3276
     *
3277
     * @param string $mailbox Mailbox name
3278
     *
3279
     * @return array|false Quota information, False on error
3280
     */
3281
    public function getQuota($mailbox = null)
3282
    {
3283
        if ($mailbox === null || $mailbox === '') {
3284
            $mailbox = 'INBOX';
3285
        }
3286
 
3287
        // a0001 GETQUOTAROOT INBOX
3288
        // * QUOTAROOT INBOX user/sample
3289
        // * QUOTA user/sample (STORAGE 654 9765)
3290
        // a0001 OK Completed
3291
 
3292
        list($code, $response) = $this->execute('GETQUOTAROOT', [$this->escape($mailbox)], 0, '/^\* QUOTA /i');
3293
 
3294
        if ($code != self::ERROR_OK) {
3295
            return false;
3296
        }
3297
 
3298
        $min_free = PHP_INT_MAX;
3299
        $result   = [];
3300
        $all      = [];
3301
 
3302
        foreach (explode("\n", $response) as $line) {
3303
            $tokens     = $this->tokenizeResponse($line, 3);
3304
            $quota_root = $tokens[2] ?? null;
3305
            $quotas     = $this->tokenizeResponse($line, 1);
3306
 
3307
            if (empty($quotas)) {
3308
                continue;
3309
            }
3310
 
3311
            foreach (array_chunk($quotas, 3) as $quota) {
3312
                list($type, $used, $total) = $quota;
3313
                $type = strtolower($type);
3314
 
3315
                if ($type && $total) {
3316
                    $all[$quota_root][$type]['used']  = intval($used);
3317
                    $all[$quota_root][$type]['total'] = intval($total);
3318
                }
3319
            }
3320
 
3321
            if (empty($all[$quota_root]['storage'])) {
3322
                continue;
3323
            }
3324
 
3325
            $used  = $all[$quota_root]['storage']['used'];
3326
            $total = $all[$quota_root]['storage']['total'];
3327
            $free  = $total - $used;
3328
 
3329
            // calculate lowest available space from all storage quotas
3330
            if ($free < $min_free) {
3331
                $min_free          = $free;
3332
                $result['used']    = $used;
3333
                $result['total']   = $total;
3334
                $result['percent'] = min(100, round(($used/max(1,$total))*100));
3335
                $result['free']    = 100 - $result['percent'];
3336
            }
3337
        }
3338
 
3339
        if (!empty($result)) {
3340
            $result['all'] = $all;
3341
        }
3342
 
3343
        return $result;
3344
    }
3345
 
3346
    /**
3347
     * Send the SETACL command (RFC4314)
3348
     *
3349
     * @param string $mailbox Mailbox name
3350
     * @param string $user    User name
3351
     * @param mixed  $acl     ACL string or array
3352
     *
3353
     * @return bool True on success, False on failure
3354
     *
3355
     * @since 0.5-beta
3356
     */
3357
    public function setACL($mailbox, $user, $acl)
3358
    {
3359
        if (is_array($acl)) {
3360
            $acl = implode('', $acl);
3361
        }
3362
 
3363
        $result = $this->execute('SETACL',
3364
            [$this->escape($mailbox), $this->escape($user), strtolower($acl)],
3365
            self::COMMAND_NORESPONSE
3366
        );
3367
 
3368
        return $result == self::ERROR_OK;
3369
    }
3370
 
3371
    /**
3372
     * Send the DELETEACL command (RFC4314)
3373
     *
3374
     * @param string $mailbox Mailbox name
3375
     * @param string $user    User name
3376
     *
3377
     * @return bool True on success, False on failure
3378
     *
3379
     * @since 0.5-beta
3380
     */
3381
    public function deleteACL($mailbox, $user)
3382
    {
3383
        $result = $this->execute('DELETEACL',
3384
            [$this->escape($mailbox), $this->escape($user)],
3385
            self::COMMAND_NORESPONSE
3386
        );
3387
 
3388
        return $result == self::ERROR_OK;
3389
    }
3390
 
3391
    /**
3392
     * Send the GETACL command (RFC4314)
3393
     *
3394
     * @param string $mailbox Mailbox name
3395
     *
3396
     * @return array User-rights array on success, NULL on error
3397
     * @since 0.5-beta
3398
     */
3399
    public function getACL($mailbox)
3400
    {
3401
        list($code, $response) = $this->execute('GETACL', [$this->escape($mailbox)], 0, '/^\* ACL /i');
3402
 
3403
        if ($code == self::ERROR_OK && $response) {
3404
            // Parse server response (remove "* ACL ")
3405
            $response = substr($response, 6);
3406
            $ret  = $this->tokenizeResponse($response);
3407
            $mbox = array_shift($ret);
3408
            $size = count($ret);
3409
 
3410
            // Create user-rights hash array
3411
            // @TODO: consider implementing fixACL() method according to RFC4314.2.1.1
3412
            // so we could return only standard rights defined in RFC4314,
3413
            // excluding 'c' and 'd' defined in RFC2086.
3414
            if ($size % 2 == 0) {
3415
                for ($i=0; $i<$size; $i++) {
3416
                    $ret[$ret[$i]] = str_split($ret[++$i]);
3417
                    unset($ret[$i-1]);
3418
                    unset($ret[$i]);
3419
                }
3420
                return $ret;
3421
            }
3422
 
3423
            $this->setError(self::ERROR_COMMAND, "Incomplete ACL response");
3424
        }
3425
    }
3426
 
3427
    /**
3428
     * Send the LISTRIGHTS command (RFC4314)
3429
     *
3430
     * @param string $mailbox Mailbox name
3431
     * @param string $user    User name
3432
     *
3433
     * @return array List of user rights
3434
     * @since 0.5-beta
3435
     */
3436
    public function listRights($mailbox, $user)
3437
    {
3438
        list($code, $response) = $this->execute('LISTRIGHTS',
3439
            [$this->escape($mailbox), $this->escape($user)], 0, '/^\* LISTRIGHTS /i');
3440
 
3441
        if ($code == self::ERROR_OK && $response) {
3442
            // Parse server response (remove "* LISTRIGHTS ")
3443
            $response = substr($response, 13);
3444
 
3445
            $ret_mbox = $this->tokenizeResponse($response, 1);
3446
            $ret_user = $this->tokenizeResponse($response, 1);
3447
            $granted  = $this->tokenizeResponse($response, 1);
3448
            $optional = trim($response);
3449
 
3450
            return [
3451
                'granted'  => str_split($granted),
3452
                'optional' => explode(' ', $optional),
3453
            ];
3454
        }
3455
    }
3456
 
3457
    /**
3458
     * Send the MYRIGHTS command (RFC4314)
3459
     *
3460
     * @param string $mailbox Mailbox name
3461
     *
3462
     * @return array MYRIGHTS response on success, NULL on error
3463
     * @since 0.5-beta
3464
     */
3465
    public function myRights($mailbox)
3466
    {
3467
        list($code, $response) = $this->execute('MYRIGHTS', [$this->escape($mailbox)], 0, '/^\* MYRIGHTS /i');
3468
 
3469
        if ($code == self::ERROR_OK && $response) {
3470
            // Parse server response (remove "* MYRIGHTS ")
3471
            $response = substr($response, 11);
3472
 
3473
            $ret_mbox = $this->tokenizeResponse($response, 1);
3474
            $rights   = $this->tokenizeResponse($response, 1);
3475
 
3476
            return str_split($rights);
3477
        }
3478
    }
3479
 
3480
    /**
3481
     * Send the SETMETADATA command (RFC5464)
3482
     *
3483
     * @param string $mailbox Mailbox name
3484
     * @param array  $entries Entry-value array (use NULL value as NIL)
3485
     *
3486
     * @return bool True on success, False on failure
3487
     * @since 0.5-beta
3488
     */
3489
    public function setMetadata($mailbox, $entries)
3490
    {
3491
        if (!is_array($entries) || empty($entries)) {
3492
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
3493
            return false;
3494
        }
3495
 
3496
        foreach ($entries as $name => $value) {
3497
            $entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true);
3498
        }
3499
 
3500
        $entries = implode(' ', $entries);
3501
        $result = $this->execute('SETMETADATA',
3502
            [$this->escape($mailbox), '(' . $entries . ')'],
3503
            self::COMMAND_NORESPONSE
3504
        );
3505
 
3506
        return $result == self::ERROR_OK;
3507
    }
3508
 
3509
    /**
3510
     * Send the SETMETADATA command with NIL values (RFC5464)
3511
     *
3512
     * @param string $mailbox Mailbox name
3513
     * @param array  $entries Entry names array
3514
     *
3515
     * @return bool True on success, False on failure
3516
     *
3517
     * @since 0.5-beta
3518
     */
3519
    public function deleteMetadata($mailbox, $entries)
3520
    {
3521
        if (!is_array($entries) && !empty($entries)) {
3522
            $entries = explode(' ', $entries);
3523
        }
3524
 
3525
        if (empty($entries)) {
3526
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
3527
            return false;
3528
        }
3529
 
3530
        $data = [];
3531
        foreach ($entries as $entry) {
3532
            $data[$entry] = null;
3533
        }
3534
 
3535
        return $this->setMetadata($mailbox, $data);
3536
    }
3537
 
3538
    /**
3539
     * Send the GETMETADATA command (RFC5464)
3540
     *
3541
     * @param string $mailbox Mailbox name
3542
     * @param array  $entries Entries
3543
     * @param array  $options Command options (with MAXSIZE and DEPTH keys)
3544
     *
3545
     * @return array GETMETADATA result on success, NULL on error
3546
     *
3547
     * @since 0.5-beta
3548
     */
3549
    public function getMetadata($mailbox, $entries, $options = [])
3550
    {
3551
        if (!is_array($entries)) {
3552
            $entries = [$entries];
3553
        }
3554
 
3555
        $args = [];
3556
 
3557
        // create options string
3558
        if (is_array($options)) {
3559
            $options = array_change_key_case($options, CASE_UPPER);
3560
            $opts    = [];
3561
 
3562
            if (!empty($options['MAXSIZE'])) {
3563
                $opts[] = 'MAXSIZE '.intval($options['MAXSIZE']);
3564
            }
3565
 
3566
            if (isset($options['DEPTH'])) {
3567
                $opts[] = 'DEPTH ' . $this->escape($options['DEPTH']);
3568
            }
3569
 
3570
            if (!empty($opts)) {
3571
                $args[] = $opts;
3572
            }
3573
        }
3574
 
3575
        $args[] = $this->escape($mailbox);
3576
        $args[] = array_map([$this, 'escape'], $entries);
3577
 
3578
        list($code, $response) = $this->execute('GETMETADATA', $args);
3579
 
3580
        if ($code == self::ERROR_OK) {
3581
            $result = [];
3582
            $data   = $this->tokenizeResponse($response);
3583
 
3584
            // The METADATA response can contain multiple entries in a single
3585
            // response or multiple responses for each entry or group of entries
3586
            for ($i = 0, $size = count($data); $i < $size; $i++) {
3587
                if ($data[$i] === '*'
3588
                    && $data[++$i] === 'METADATA'
3589
                    && is_string($mbox = $data[++$i])
3590
                    && is_array($data[++$i])
3591
                ) {
3592
                    for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) {
3593
                        if ($data[$i][$x+1] !== null) {
3594
                            $result[$mbox][$data[$i][$x]] = $data[$i][$x+1];
3595
                        }
3596
                    }
3597
                }
3598
            }
3599
 
3600
            return $result;
3601
        }
3602
    }
3603
 
3604
    /**
3605
     * Send the SETANNOTATION command (draft-daboo-imap-annotatemore)
3606
     *
3607
     * @param string $mailbox Mailbox name
3608
     * @param array  $data    Data array where each item is an array with
3609
     *                        three elements: entry name, attribute name, value
3610
     *
3611
     * @return bool True on success, False on failure
3612
     * @since 0.5-beta
3613
     */
3614
    public function setAnnotation($mailbox, $data)
3615
    {
3616
        if (!is_array($data) || empty($data)) {
3617
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
3618
            return false;
3619
        }
3620
 
3621
        foreach ($data as $entry) {
3622
            // ANNOTATEMORE drafts before version 08 require quoted parameters
3623
            $entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true),
3624
                $this->escape($entry[1], true), $this->escape($entry[2], true));
3625
        }
3626
 
3627
        $entries = implode(' ', $entries);
3628
        $result  = $this->execute('SETANNOTATION', [$this->escape($mailbox), $entries], self::COMMAND_NORESPONSE);
3629
 
3630
        return $result == self::ERROR_OK;
3631
    }
3632
 
3633
    /**
3634
     * Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore)
3635
     *
3636
     * @param string $mailbox Mailbox name
3637
     * @param array  $data    Data array where each item is an array with
3638
     *                        two elements: entry name and attribute name
3639
     *
3640
     * @return bool True on success, False on failure
3641
     *
3642
     * @since 0.5-beta
3643
     */
3644
    public function deleteAnnotation($mailbox, $data)
3645
    {
3646
        if (!is_array($data) || empty($data)) {
3647
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
3648
            return false;
3649
        }
3650
 
3651
        return $this->setAnnotation($mailbox, $data);
3652
    }
3653
 
3654
    /**
3655
     * Send the GETANNOTATION command (draft-daboo-imap-annotatemore)
3656
     *
3657
     * @param string $mailbox Mailbox name
3658
     * @param array  $entries Entries names
3659
     * @param array  $attribs Attribs names
3660
     *
3661
     * @return array Annotations result on success, NULL on error
3662
     *
3663
     * @since 0.5-beta
3664
     */
3665
    public function getAnnotation($mailbox, $entries, $attribs)
3666
    {
3667
        if (!is_array($entries)) {
3668
            $entries = [$entries];
3669
        }
3670
 
3671
        // create entries string
3672
        // ANNOTATEMORE drafts before version 08 require quoted parameters
3673
        foreach ($entries as $idx => $name) {
3674
            $entries[$idx] = $this->escape($name, true);
3675
        }
3676
        $entries = '(' . implode(' ', $entries) . ')';
3677
 
3678
        if (!is_array($attribs)) {
3679
            $attribs = [$attribs];
3680
        }
3681
 
3682
        // create attributes string
3683
        foreach ($attribs as $idx => $name) {
3684
            $attribs[$idx] = $this->escape($name, true);
3685
        }
3686
        $attribs = '(' . implode(' ', $attribs) . ')';
3687
 
3688
        list($code, $response) = $this->execute('GETANNOTATION', [$this->escape($mailbox), $entries, $attribs]);
3689
 
3690
        if ($code == self::ERROR_OK) {
3691
            $result     = [];
3692
            $data       = $this->tokenizeResponse($response);
3693
            $last_entry = null;
3694
 
3695
            // Here we returns only data compatible with METADATA result format
3696
            if (!empty($data) && ($size = count($data))) {
3697
                for ($i=0; $i<$size; $i++) {
3698
                    $entry = $data[$i];
3699
                    if (isset($mbox) && is_array($entry)) {
3700
                        $attribs = $entry;
3701
                        $entry   = $last_entry;
3702
                    }
3703
                    else if ($entry == '*') {
3704
                        if ($data[$i+1] == 'ANNOTATION') {
3705
                            $mbox = $data[$i+2];
3706
                            unset($data[$i]);   // "*"
3707
                            unset($data[++$i]); // "ANNOTATION"
3708
                            unset($data[++$i]); // Mailbox
3709
                        }
3710
                        // get rid of other untagged responses
3711
                        else {
3712
                            unset($mbox);
3713
                            unset($data[$i]);
3714
                        }
3715
                        continue;
3716
                    }
3717
                    else if (isset($mbox)) {
3718
                        $attribs = $data[++$i];
3719
                    }
3720
                    else {
3721
                        unset($data[$i]);
3722
                        continue;
3723
                    }
3724
 
3725
                    if (!empty($attribs)) {
3726
                        for ($x=0, $len=count($attribs); $x<$len;) {
3727
                            $attr  = $attribs[$x++];
3728
                            $value = $attribs[$x++];
3729
                            if ($attr == 'value.priv' && $value !== null) {
3730
                                $result[$mbox]['/private' . $entry] = $value;
3731
                            }
3732
                            else if ($attr == 'value.shared' && $value !== null) {
3733
                                $result[$mbox]['/shared' . $entry] = $value;
3734
                            }
3735
                        }
3736
                    }
3737
 
3738
                    $last_entry = $entry;
3739
                    unset($data[$i]);
3740
                }
3741
            }
3742
 
3743
            return $result;
3744
        }
3745
    }
3746
 
3747
    /**
3748
     * Returns BODYSTRUCTURE for the specified message.
3749
     *
3750
     * @param string $mailbox Folder name
3751
     * @param int    $id      Message sequence number or UID
3752
     * @param bool   $is_uid  True if $id is an UID
3753
     *
3754
     * @return array|bool Body structure array or False on error.
3755
     * @since 0.6
3756
     */
3757
    public function getStructure($mailbox, $id, $is_uid = false)
3758
    {
3759
        $result = $this->fetch($mailbox, $id, $is_uid, ['BODYSTRUCTURE']);
3760
 
3761
        if (is_array($result) && !empty($result)) {
3762
            $result = array_shift($result);
3763
            return $result->bodystructure;
3764
        }
3765
 
3766
        return false;
3767
    }
3768
 
3769
    /**
3770
     * Returns data of a message part according to specified structure.
3771
     *
3772
     * @param array  $structure Message structure (getStructure() result)
3773
     * @param string $part      Message part identifier
3774
     *
3775
     * @return array Part data as hash array (type, encoding, charset, size)
3776
     */
3777
    public static function getStructurePartData($structure, $part)
3778
    {
3779
        $part_a = self::getStructurePartArray($structure, $part);
3780
        $data   = [];
3781
 
3782
        if (empty($part_a)) {
3783
            return $data;
3784
        }
3785
 
3786
        // content-type
3787
        if (is_array($part_a[0])) {
3788
            $data['type'] = 'multipart';
3789
        }
3790
        else {
3791
            $data['type']     = strtolower($part_a[0]);
3792
            $data['subtype']  = strtolower($part_a[1]);
3793
            $data['encoding'] = strtolower($part_a[5]);
3794
 
3795
            // charset
3796
            if (is_array($part_a[2])) {
3797
               foreach ($part_a[2] as $key => $val) {
3798
                    if (strcasecmp($val, 'charset') == 0) {
3799
                        $data['charset'] = $part_a[2][$key+1];
3800
                        break;
3801
                    }
3802
                }
3803
            }
3804
        }
3805
 
3806
        // size
3807
        $data['size'] = intval($part_a[6]);
3808
 
3809
        return $data;
3810
    }
3811
 
3812
    public static function getStructurePartArray($a, $part)
3813
    {
3814
        if (!is_array($a)) {
3815
            return false;
3816
        }
3817
 
3818
        if (empty($part)) {
3819
            return $a;
3820
        }
3821
 
3822
        $ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : '';
3823
 
3824
        if (strcasecmp($ctype, 'message/rfc822') == 0) {
3825
            $a = $a[8];
3826
        }
3827
 
3828
        if (strpos($part, '.') > 0) {
3829
            $orig_part = $part;
3830
            $pos       = strpos($part, '.');
3831
            $rest      = substr($orig_part, $pos+1);
3832
            $part      = substr($orig_part, 0, $pos);
3833
 
3834
            return self::getStructurePartArray($a[$part-1], $rest);
3835
        }
3836
        else if ($part > 0) {
3837
            return is_array($a[$part-1]) ? $a[$part-1] : $a;
3838
        }
3839
    }
3840
 
3841
    /**
3842
     * Creates next command identifier (tag)
3843
     *
3844
     * @return string Command identifier
3845
     * @since 0.5-beta
3846
     */
3847
    public function nextTag()
3848
    {
3849
        $this->cmd_num++;
3850
        $this->cmd_tag = sprintf('A%04d', $this->cmd_num);
3851
 
3852
        return $this->cmd_tag;
3853
    }
3854
 
3855
    /**
3856
     * Sends IMAP command and parses result
3857
     *
3858
     * @param string $command   IMAP command
3859
     * @param array  $arguments Command arguments
3860
     * @param int    $options   Execution options
3861
     * @param string $filter    Line filter (regexp)
3862
     *
3863
     * @return mixed Response code or list of response code and data
3864
     * @since 0.5-beta
3865
     */
3866
    public function execute($command, $arguments = [], $options = 0, $filter = null)
3867
    {
3868
        $tag      = $this->nextTag();
3869
        $query    = $tag . ' ' . $command;
3870
        $noresp   = ($options & self::COMMAND_NORESPONSE);
3871
        $response = $noresp ? null : '';
3872
 
3873
        if (!empty($arguments)) {
3874
            foreach ($arguments as $arg) {
3875
                $query .= ' ' . self::r_implode($arg);
3876
            }
3877
        }
3878
 
3879
        // Send command
3880
        if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) {
3881
            preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches);
3882
            $cmd = $matches[1] ?: 'UNKNOWN';
3883
            $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
3884
 
3885
            return $noresp ? self::ERROR_COMMAND : [self::ERROR_COMMAND, ''];
3886
        }
3887
 
3888
        // Parse response
3889
        do {
3890
            $line = $this->readFullLine(4096);
3891
 
3892
            if ($response !== null) {
3893
                if (!$filter || preg_match($filter, $line)) {
3894
                    $response .= $line;
3895
                }
3896
            }
3897
 
3898
            // parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851)
3899
            if ($line && $command == 'UID MOVE') {
3900
                if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) {
3901
                    $this->data['COPYUID'] = [$m[1], $m[2]];
3902
                }
3903
            }
3904
        }
3905
        while (!$this->startsWith($line, $tag . ' ', true, true));
3906
 
3907
        $code = $this->parseResult($line, $command . ': ');
3908
 
3909
        // Remove last line from response
3910
        if ($response) {
3911
            if (!$filter) {
3912
                $line_len = min(strlen($response), strlen($line));
3913
                $response = substr($response, 0, -$line_len);
3914
            }
3915
 
3916
            $response = rtrim($response, "\r\n");
3917
        }
3918
 
3919
        // optional CAPABILITY response
3920
        if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK
3921
            && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)
3922
        ) {
3923
            $this->parseCapability($matches[1], true);
3924
        }
3925
 
3926
        // return last line only (without command tag, result and response code)
3927
        if ($line && ($options & self::COMMAND_LASTLINE)) {
3928
            $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line));
3929
        }
3930
 
3931
        return $noresp ? $code : [$code, $response];
3932
    }
3933
 
3934
    /**
3935
     * Splits IMAP response into string tokens
3936
     *
3937
     * @param string &$str The IMAP's server response
3938
     * @param int    $num  Number of tokens to return
3939
     *
3940
     * @return mixed Tokens array or string if $num=1
3941
     * @since 0.5-beta
3942
     */
3943
    public static function tokenizeResponse(&$str, $num=0)
3944
    {
3945
        $result = [];
3946
 
3947
        while (!$num || count($result) < $num) {
3948
            // remove spaces from the beginning of the string
3949
            $str = ltrim($str);
3950
 
3951
            // empty string
3952
            if ($str === '' || $str === null) {
3953
                break;
3954
            }
3955
 
3956
            switch ($str[0]) {
3957
 
3958
            // String literal
3959
            case '{':
3960
                if (($epos = strpos($str, "}\r\n", 1)) == false) {
3961
                    // error
3962
                }
3963
                if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) {
3964
                    // error
3965
                }
3966
 
3967
                $result[] = $bytes ? substr($str, $epos + 3, $bytes) : '';
3968
                $str      = substr($str, $epos + 3 + $bytes);
3969
                break;
3970
 
3971
            // Quoted string
3972
            case '"':
3973
                $len = strlen($str);
3974
 
3975
                for ($pos=1; $pos<$len; $pos++) {
3976
                    if ($str[$pos] == '"') {
3977
                        break;
3978
                    }
3979
                    if ($str[$pos] == "\\") {
3980
                        if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
3981
                            $pos++;
3982
                        }
3983
                    }
3984
                }
3985
 
3986
                // we need to strip slashes for a quoted string
3987
                $result[] = stripslashes(substr($str, 1, $pos - 1));
3988
                $str      = substr($str, $pos + 1);
3989
                break;
3990
 
3991
            // Parenthesized list
3992
            case '(':
3993
                $str      = substr($str, 1);
3994
                $result[] = self::tokenizeResponse($str);
3995
                break;
3996
 
3997
            case ')':
3998
                $str = substr($str, 1);
3999
                return $result;
4000
 
4001
            // String atom, number, astring, NIL, *, %
4002
            default:
4003
                // excluded chars: SP, CTL, ), DEL
4004
                // we do not exclude [ and ] (#1489223)
4005
                if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) {
4006
                    $result[] = $m[1] == 'NIL' ? null : $m[1];
4007
                    $str      = substr($str, strlen($m[1]));
4008
                }
4009
 
4010
                break;
4011
            }
4012
        }
4013
 
4014
        return $num == 1 ? ($result[0] ?? '') : $result;
4015
    }
4016
 
4017
    /**
4018
     * Joins IMAP command line elements (recursively)
4019
     */
4020
    protected static function r_implode($element)
4021
    {
4022
        if (!is_array($element)) {
4023
            return $element;
4024
        }
4025
 
4026
        reset($element);
4027
 
4028
        $string = '';
4029
 
4030
        foreach ($element as $value) {
4031
            $string .= ' ' . self::r_implode($value);
4032
        }
4033
 
4034
        return '(' . trim($string) . ')';
4035
    }
4036
 
4037
    /**
4038
     * Converts message identifiers array into sequence-set syntax
4039
     *
4040
     * @param array $messages Message identifiers
4041
     * @param bool  $force    Forces compression of any size
4042
     *
4043
     * @return string Compressed sequence-set
4044
     */
4045
    public static function compressMessageSet($messages, $force = false)
4046
    {
4047
        // given a comma delimited list of independent mid's,
4048
        // compresses by grouping sequences together
4049
        if (!is_array($messages)) {
4050
            // if less than 255 bytes long, let's not bother
4051
            if (!$force && strlen($messages) < 255) {
4052
                return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
4053
            }
4054
 
4055
            // see if it's already been compressed
4056
            if (strpos($messages, ':') !== false) {
4057
                return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
4058
            }
4059
 
4060
            // separate, then sort
4061
            $messages = explode(',', $messages);
4062
        }
4063
 
4064
        sort($messages);
4065
 
4066
        $result = [];
4067
        $start  = $prev = $messages[0];
4068
 
4069
        foreach ($messages as $id) {
4070
            $incr = $id - $prev;
4071
            if ($incr > 1) { // found a gap
4072
                if ($start == $prev) {
4073
                    $result[] = $prev; // push single id
4074
                }
4075
                else {
4076
                    $result[] = $start . ':' . $prev; // push sequence as start_id:end_id
4077
                }
4078
                $start = $id; // start of new sequence
4079
            }
4080
            $prev = $id;
4081
        }
4082
 
4083
        // handle the last sequence/id
4084
        if ($start == $prev) {
4085
            $result[] = $prev;
4086
        }
4087
        else {
4088
            $result[] = $start.':'.$prev;
4089
        }
4090
 
4091
        // return as comma separated string
4092
        $result = implode(',', $result);
4093
 
4094
        return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result;
4095
    }
4096
 
4097
    /**
4098
     * Converts message sequence-set into array
4099
     *
4100
     * @param string $messages Message identifiers
4101
     *
4102
     * @return array List of message identifiers
4103
     */
4104
    public static function uncompressMessageSet($messages)
4105
    {
4106
        if (empty($messages)) {
4107
            return [];
4108
        }
4109
 
4110
        $result   = [];
4111
        $messages = explode(',', $messages);
4112
 
4113
        foreach ($messages as $idx => $part) {
4114
            $items = explode(':', $part);
4115
 
4116
            if (!empty($items[1]) && $items[1] > $items[0]) {
4117
                $max = $items[1];
4118
            }
4119
            else {
4120
                $max = $items[0];
4121
            }
4122
 
4123
            for ($x = $items[0]; $x <= $max; $x++) {
4124
                $result[] = (int) $x;
4125
            }
4126
 
4127
            unset($messages[$idx]);
4128
        }
4129
 
4130
        return $result;
4131
    }
4132
 
4133
    /**
4134
     * Clear internal status cache
4135
     */
4136
    protected function clear_status_cache($mailbox)
4137
    {
4138
        unset($this->data['STATUS:' . $mailbox]);
4139
 
4140
        $keys = ['EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP'];
4141
 
4142
        foreach ($keys as $key) {
4143
            unset($this->data[$key]);
4144
        }
4145
    }
4146
 
4147
    /**
4148
     * Clear internal cache of the current mailbox
4149
     */
4150
    protected function clear_mailbox_cache()
4151
    {
4152
        $this->clear_status_cache($this->selected);
4153
 
4154
        $keys = ['UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ',
4155
            'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE'];
4156
 
4157
        foreach ($keys as $key) {
4158
            unset($this->data[$key]);
4159
        }
4160
    }
4161
 
4162
    /**
4163
     * Converts flags array into string for inclusion in IMAP command
4164
     *
4165
     * @param array $flags Flags (see self::flags)
4166
     *
4167
     * @return string Space-separated list of flags
4168
     */
4169
    protected function flagsToStr($flags)
4170
    {
4171
        foreach ((array) $flags as $idx => $flag) {
4172
            if ($flag = $this->flags[strtoupper($flag)]) {
4173
                $flags[$idx] = $flag;
4174
            }
4175
        }
4176
 
4177
        return implode(' ', (array) $flags);
4178
    }
4179
 
4180
    /**
4181
     * CAPABILITY response parser
4182
     */
4183
    protected function parseCapability($str, $trusted=false)
4184
    {
4185
        $str = preg_replace('/^\* CAPABILITY /i', '', $str);
4186
 
4187
        $this->capability = explode(' ', strtoupper($str));
4188
 
4189
        if (!empty($this->prefs['disabled_caps'])) {
4190
            $this->capability = array_diff($this->capability, $this->prefs['disabled_caps']);
4191
        }
4192
 
4193
        if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) {
4194
            $this->prefs['literal+'] = true;
4195
        }
4196
        else if (!isset($this->prefs['literal-']) && in_array('LITERAL-', $this->capability)) {
4197
            $this->prefs['literal-'] = true;
4198
        }
4199
 
4200
        if ($trusted) {
4201
            $this->capability_read = true;
4202
        }
4203
    }
4204
 
4205
    /**
4206
     * Escapes a string when it contains special characters (RFC3501)
4207
     *
4208
     * @param string $string       IMAP string
4209
     * @param bool   $force_quotes Forces string quoting (for atoms)
4210
     *
4211
     * @return string String atom, quoted-string or string literal
4212
     * @todo lists
4213
     */
4214
    public static function escape($string, $force_quotes = false)
4215
    {
4216
        if ($string === null) {
4217
            return 'NIL';
4218
        }
4219
 
4220
        if ($string === '') {
4221
            return '""';
4222
        }
4223
 
4224
        // atom-string (only safe characters)
4225
        if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) {
4226
            return $string;
4227
        }
4228
 
4229
        // quoted-string
4230
        if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
4231
            return '"' . addcslashes($string, '\\"') . '"';
4232
        }
4233
 
4234
        // literal-string
4235
        return sprintf("{%d}\r\n%s", strlen($string), $string);
4236
    }
4237
 
4238
    /**
4239
     * Set the value of the debugging flag.
4240
     *
4241
     * @param bool     $debug   New value for the debugging flag.
4242
     * @param callable $handler Logging handler function
4243
     *
4244
     * @since 0.5-stable
4245
     */
4246
    public function setDebug($debug, $handler = null)
4247
    {
4248
        $this->debug         = $debug;
4249
        $this->debug_handler = $handler;
4250
    }
4251
 
4252
    /**
4253
     * Write the given debug text to the current debug output handler.
4254
     *
4255
     * @param string $message Debug message text.
4256
     *
4257
     * @since 0.5-stable
4258
     */
4259
    protected function debug($message)
4260
    {
4261
        if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) {
4262
            $diff    = $len - self::DEBUG_LINE_LENGTH;
4263
            $message = substr($message, 0, self::DEBUG_LINE_LENGTH)
4264
                . "... [truncated $diff bytes]";
4265
        }
4266
 
4267
        if ($this->resourceid) {
4268
            $message = sprintf('[%s] %s', $this->resourceid, $message);
4269
        }
4270
 
4271
        if ($this->debug_handler) {
4272
            call_user_func_array($this->debug_handler, [$this, $message]);
4273
        }
4274
        else {
4275
            echo "DEBUG: $message\n";
4276
        }
4277
    }
4278
}