Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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   = '';
1441 ariadna 679
                $success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], '', 0, 0, $token);
1 efrain 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'])) {
1441 ariadna 1057
            $options = array_intersect_key($this->prefs['socket_options'], ['ssl' => 1, 'socket' => 1]);
1058
            $context = stream_context_create($options);
1 efrain 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;
1441 ariadna 2523
                $result[$id]->folder = $mailbox;
1 efrain 2524
 
2525
                $headers = null;
2526
                $line    = substr($line, strlen($m[0]) + 2);
2527
 
2528
                // Tokenize response and assign to object properties
2529
                while (($tokens = $this->tokenizeResponse($line, 2)) && count($tokens) == 2) {
2530
                    list($name, $value) = $tokens;
2531
                    if ($name == 'UID') {
2532
                        $result[$id]->uid = intval($value);
2533
                    }
2534
                    else if ($name == 'RFC822.SIZE') {
2535
                        $result[$id]->size = intval($value);
2536
                    }
2537
                    else if ($name == 'RFC822.TEXT') {
2538
                        $result[$id]->body = $value;
2539
                    }
2540
                    else if ($name == 'INTERNALDATE') {
2541
                        $result[$id]->internaldate = $value;
2542
                        $result[$id]->date         = $value;
2543
                        $result[$id]->timestamp    = rcube_utils::strtotime($value);
2544
                    }
2545
                    else if ($name == 'FLAGS') {
2546
                        if (!empty($value)) {
2547
                            foreach ((array)$value as $flag) {
2548
                                $flag = str_replace(['$', "\\"], '', $flag);
2549
                                $flag = strtoupper($flag);
2550
 
2551
                                $result[$id]->flags[$flag] = true;
2552
                            }
2553
                        }
2554
                    }
1441 ariadna 2555
                    else if ($name == 'ANNOTATION') {
2556
                        $result[$id]->annotations = [];
2557
                        if (!empty($value) && is_array($value)) {
2558
                            $n = 0;
2559
                            while (!empty($value[$n]) && is_string($value[$n])) {
2560
                                $name = $value[$n++];
2561
                                $list = $value[$n++];
2562
                                $result[$id]->annotations[$name] = [];
2563
                                $c = 0;
2564
                                while (!empty($list[$c]) && is_string($list[$c])) {
2565
                                    $result[$id]->annotations[$name][$list[$c++]] = $list[$c++];
2566
                                }
2567
                            }
2568
                        }
2569
                    }
1 efrain 2570
                    else if ($name == 'MODSEQ') {
2571
                        $result[$id]->modseq = $value[0];
2572
                    }
2573
                    else if ($name == 'ENVELOPE') {
2574
                        $result[$id]->envelope = $value;
2575
                    }
2576
                    else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) {
2577
                        if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) {
2578
                            $value = [$value];
2579
                        }
2580
                        $result[$id]->bodystructure = $value;
2581
                    }
2582
                    else if ($name == 'RFC822') {
2583
                        $result[$id]->body = $value;
2584
                    }
2585
                    else if (stripos($name, 'BODY[') === 0) {
2586
                        $name = str_replace(']', '', substr($name, 5));
2587
 
2588
                        if ($name == 'HEADER.FIELDS') {
2589
                            // skip ']' after headers list
2590
                            $this->tokenizeResponse($line, 1);
2591
                            $headers = $this->tokenizeResponse($line, 1);
2592
                        }
2593
                        else if (strlen($name)) {
2594
                            $result[$id]->bodypart[$name] = $value;
2595
                        }
2596
                        else {
2597
                            $result[$id]->body = $value;
2598
                        }
2599
                    }
2600
                }
2601
 
2602
                // create array with header field:data
2603
                if (!empty($headers)) {
2604
                    $headers = explode("\n", trim($headers));
2605
                    $lines   = [];
2606
                    $ln      = 0;
2607
 
2608
                    foreach ($headers as $resln) {
2609
                        if (!isset($resln[0]) || ord($resln[0]) <= 32) {
2610
                            $lines[$ln] = ($lines[$ln] ?? '') . (empty($lines[$ln]) ? '' : "\n") . trim($resln);
2611
                        }
2612
                        else {
2613
                            $lines[++$ln] = trim($resln);
2614
                        }
2615
                    }
2616
 
2617
                    foreach ($lines as $str) {
2618
                        if (strpos($str, ':') === false) {
2619
                            continue;
2620
                        }
2621
 
2622
                        list($field, $string) = explode(':', $str, 2);
2623
 
2624
                        $field  = strtolower($field);
2625
                        $string = preg_replace('/\n[\t\s]*/', ' ', trim($string));
2626
 
2627
                        switch ($field) {
2628
                        case 'date';
2629
                            $string                 = substr($string, 0, 128);
2630
                            $result[$id]->date      = $string;
2631
                            $result[$id]->timestamp = rcube_utils::strtotime($string);
2632
                            break;
2633
                        case 'to':
2634
                            $result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string);
2635
                            break;
2636
                        case 'from':
2637
                        case 'subject':
2638
                            $string = substr($string, 0, 2048);
2639
                        case 'cc':
2640
                        case 'bcc':
2641
                        case 'references':
2642
                            $result[$id]->{$field} = $string;
2643
                            break;
2644
                        case 'reply-to':
2645
                            $result[$id]->replyto = $string;
2646
                            break;
2647
                        case 'content-transfer-encoding':
2648
                            $result[$id]->encoding = substr($string, 0, 32);
2649
                        break;
2650
                        case 'content-type':
2651
                            $ctype_parts = preg_split('/[; ]+/', $string);
2652
                            $result[$id]->ctype = strtolower(array_shift($ctype_parts));
2653
                            if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) {
2654
                                $result[$id]->charset = $regs[1];
2655
                            }
2656
                            break;
2657
                        case 'in-reply-to':
2658
                            $result[$id]->in_reply_to = str_replace(["\n", '<', '>'], '', $string);
2659
                            break;
2660
                        case 'disposition-notification-to':
2661
                        case 'x-confirm-reading-to':
2662
                            $result[$id]->mdn_to = substr($string, 0, 2048);
2663
                            break;
2664
                        case 'message-id':
2665
                            $result[$id]->messageID = substr($string, 0, 2048);
2666
                            break;
2667
                        case 'x-priority':
2668
                            if (preg_match('/^(\d+)/', $string, $matches)) {
2669
                                $result[$id]->priority = intval($matches[1]);
2670
                            }
2671
                            break;
2672
                        default:
2673
                            if (strlen($field) < 3) {
2674
                                break;
2675
                            }
2676
                            if (!empty($result[$id]->others[$field])) {
2677
                                $string = array_merge((array) $result[$id]->others[$field], (array) $string);
2678
                            }
2679
                            $result[$id]->others[$field] = $string;
2680
                        }
2681
                    }
2682
                }
2683
            }
2684
            // VANISHED response (QRESYNC RFC5162)
2685
            // Sample: * VANISHED (EARLIER) 300:310,405,411
2686
            else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
2687
                $line   = substr($line, strlen($match[0]));
2688
                $v_data = $this->tokenizeResponse($line, 1);
2689
 
2690
                $this->data['VANISHED'] = $v_data;
2691
            }
2692
        }
2693
        while (!$this->startsWith($line, $key, true));
2694
 
2695
        return $result;
2696
    }
2697
 
2698
    /**
2699
     * Returns message(s) data (flags, headers, etc.)
2700
     *
2701
     * @param string $mailbox     Mailbox name
2702
     * @param mixed  $message_set Message(s) sequence identifier(s) or UID(s)
2703
     * @param bool   $is_uid      True if $message_set contains UIDs
2704
     * @param bool   $bodystr     Enable to add BODYSTRUCTURE data to the result
1441 ariadna 2705
     * @param array  $add_headers List of additional headers to fetch
2706
     * @param array  $query_items List of additional items to fetch
1 efrain 2707
     *
2708
     * @return bool|array List of rcube_message_header elements, False on error
2709
     */
1441 ariadna 2710
    public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = [], $query_items = [])
1 efrain 2711
    {
1441 ariadna 2712
        $query_items = array_unique(array_merge($query_items, ['UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE']));
2713
        $headers = ['DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO',
1 efrain 2714
            'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY'];
2715
 
2716
        if (!empty($add_headers)) {
2717
            $add_headers = array_map('strtoupper', $add_headers);
2718
            $headers     = array_unique(array_merge($headers, $add_headers));
2719
        }
2720
 
2721
        if ($bodystr) {
2722
            $query_items[] = 'BODYSTRUCTURE';
2723
        }
2724
 
2725
        $query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]';
2726
 
2727
        return $this->fetch($mailbox, $message_set, $is_uid, $query_items);
2728
    }
2729
 
2730
    /**
2731
     * Returns message data (flags, headers, etc.)
2732
     *
2733
     * @param string $mailbox     Mailbox name
2734
     * @param int    $id          Message sequence identifier or UID
2735
     * @param bool   $is_uid      True if $id is an UID
2736
     * @param bool   $bodystr     Enable to add BODYSTRUCTURE data to the result
2737
     * @param array  $add_headers List of additional headers
1441 ariadna 2738
     * @param array  $query_items List of additional items to fetch
1 efrain 2739
     *
2740
     * @return bool|rcube_message_header Message data, False on error
2741
     */
1441 ariadna 2742
    public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = [], $query_items = [])
1 efrain 2743
    {
1441 ariadna 2744
        $a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers, $query_items);
1 efrain 2745
 
2746
        if (is_array($a)) {
2747
            return array_shift($a);
2748
        }
2749
 
2750
        return false;
2751
    }
2752
 
2753
    /**
2754
     * Sort messages by specified header field
2755
     *
2756
     * @param array  $messages Array of rcube_message_header objects
2757
     * @param string $field    Name of the property to sort by
2758
     * @param string $order    Sorting order (ASC|DESC)
2759
     *
2760
     * @return array Sorted input array
2761
     */
2762
    public static function sortHeaders($messages, $field, $order = 'ASC')
2763
    {
2764
        $field = empty($field) ? 'uid' : strtolower($field);
2765
        $order = empty($order) ? 'ASC' : strtoupper($order);
2766
        $index = [];
2767
 
2768
        reset($messages);
2769
 
2770
        // Create an index
2771
        foreach ($messages as $key => $headers) {
2772
            switch ($field) {
2773
            case 'arrival':
2774
                $field = 'internaldate';
2775
                // no-break
2776
            case 'date':
2777
            case 'internaldate':
2778
            case 'timestamp':
2779
                $value = rcube_utils::strtotime($headers->$field);
2780
                if (!$value && $field != 'timestamp') {
2781
                    $value = $headers->timestamp;
2782
                }
2783
 
2784
                break;
2785
 
2786
            default:
2787
                // @TODO: decode header value, convert to UTF-8
2788
                $value = $headers->$field;
2789
                if (is_string($value)) {
2790
                    $value = str_replace('"', '', $value);
2791
 
2792
                    if ($field == 'subject') {
2793
                        $value = rcube_utils::remove_subject_prefix($value);
2794
                    }
2795
                }
2796
            }
2797
 
2798
            $index[$key] = $value;
2799
        }
2800
 
2801
        $sort_order = $order == 'ASC' ? SORT_ASC : SORT_DESC;
2802
        $sort_flags = SORT_STRING | SORT_FLAG_CASE;
2803
 
2804
        if (in_array($field, ['arrival', 'date', 'internaldate', 'timestamp', 'size', 'uid', 'id'])) {
2805
            $sort_flags = SORT_NUMERIC;
2806
        }
2807
 
2808
        array_multisort($index, $sort_order, $sort_flags, $messages);
2809
 
2810
        return $messages;
2811
    }
2812
 
2813
    /**
2814
     * Fetch MIME headers of specified message parts
2815
     *
2816
     * @param string $mailbox Mailbox name
2817
     * @param int    $uid     Message UID
2818
     * @param array  $parts   Message part identifiers
2819
     * @param bool   $mime    Use MIME instead of HEADER
2820
     *
2821
     * @return array|bool Array containing headers string for each specified body
2822
     *                    False on failure.
2823
     */
2824
    public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true)
2825
    {
2826
        if (!$this->select($mailbox)) {
2827
            return false;
2828
        }
2829
 
2830
        $parts  = (array) $parts;
2831
        $key    = $this->nextTag();
2832
        $peeks  = [];
2833
        $type   = $mime ? 'MIME' : 'HEADER';
2834
 
2835
        // format request
2836
        foreach ($parts as $part) {
2837
            $peeks[] = "BODY.PEEK[$part.$type]";
2838
        }
2839
 
2840
        $request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')';
2841
 
2842
        // send request
2843
        if (!$this->putLine($request)) {
2844
            $this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command");
2845
            return false;
2846
        }
2847
 
2848
        $result = [];
2849
 
2850
        do {
2851
            $line = $this->readLine(1024);
2852
            if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) {
2853
                $line = ltrim(substr($line, strlen($m[0])));
2854
                while (preg_match('/^\s*BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) {
2855
                    $line = substr($line, strlen($matches[0]));
2856
                    $result[$matches[1]] = trim($this->multLine($line));
2857
                    $line = $this->readLine(1024);
2858
                }
2859
            }
2860
        }
2861
        while (!$this->startsWith($line, $key, true));
2862
 
2863
        return $result;
2864
    }
2865
 
2866
    /**
2867
     * Fetches message part header
2868
     */
2869
    public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null)
2870
    {
2871
        $part = empty($part) ? 'HEADER' : $part.'.MIME';
2872
 
2873
        return $this->handlePartBody($mailbox, $id, $is_uid, $part);
2874
    }
2875
 
2876
    /**
2877
     * Fetches body of the specified message part
2878
     */
2879
    public function handlePartBody($mailbox, $id, $is_uid = false, $part = '', $encoding = null, $print = null,
2880
        $file = null, $formatted = false, $max_bytes = 0)
2881
    {
2882
        if (!$this->select($mailbox)) {
2883
            return false;
2884
        }
2885
 
2886
        $binary    = true;
2887
        $initiated = false;
2888
 
2889
        do {
2890
            if (!$initiated) {
2891
                switch ($encoding) {
2892
                case 'base64':
2893
                    $mode = 1;
2894
                    break;
2895
                case 'quoted-printable':
2896
                    $mode = 2;
2897
                    break;
2898
                case 'x-uuencode':
2899
                case 'x-uue':
2900
                case 'uue':
2901
                case 'uuencode':
2902
                    $mode = 3;
2903
                    break;
2904
                default:
2905
                    $mode = $formatted ? 4 : 0;
2906
                }
2907
 
2908
                // Use BINARY extension when possible (and safe)
2909
                $binary     = $binary && $mode && preg_match('/^[0-9.]+$/', (string) $part) && $this->hasCapability('BINARY');
2910
                $fetch_mode = $binary ? 'BINARY' : 'BODY';
2911
                $partial    = $max_bytes ? sprintf('<0.%d>', $max_bytes) : '';
2912
 
2913
                // format request
2914
                $key       = $this->nextTag();
2915
                $cmd       = ($is_uid ? 'UID ' : '') . 'FETCH';
2916
                $request   = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)";
2917
                $result    = false;
2918
                $found     = false;
2919
                $initiated = true;
2920
 
2921
                // send request
2922
                if (!$this->putLine($request)) {
2923
                    $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
2924
                    return false;
2925
                }
2926
 
2927
                if ($binary) {
2928
                    // WARNING: Use $formatted argument with care, this may break binary data stream
2929
                    $mode = -1;
2930
                }
2931
            }
2932
 
2933
            $line = trim($this->readLine(1024));
2934
 
2935
            if (!$line) {
2936
                break;
2937
            }
2938
 
2939
            // handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request
2940
            if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) {
2941
                $binary = $initiated = false;
2942
                continue;
2943
            }
2944
 
2945
            // skip irrelevant untagged responses (we have a result already)
2946
            if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) {
2947
                continue;
2948
            }
2949
 
2950
            $line = $m[2];
2951
 
2952
            // handle one line response
2953
            if ($line[0] == '(' && substr($line, -1) == ')') {
2954
                // tokenize content inside brackets
2955
                // the content can be e.g.: (UID 9844 BODY[2.4] NIL)
2956
                $line = preg_replace('/(^\(|\)$)/', '', $line);
2957
                $tokens = $this->tokenizeResponse($line);
2958
 
2959
                for ($i=0; $i<count($tokens); $i+=2) {
2960
                    if (preg_match('/^(BODY|BINARY)/i', $tokens[$i])) {
2961
                        $result = $tokens[$i+1];
2962
                        $found  = true;
2963
                        break;
2964
                    }
2965
                }
2966
 
2967
                // Cyrus IMAP does not return a NO-response on error, but we can detect it
2968
                // and fallback to a non-binary fetch (#9097)
2969
                if ($binary && !$found) {
2970
                    $binary = $initiated = false;
2971
                    $line = trim($this->readLine(1024)); // the OK response line
2972
                    continue;
2973
                }
2974
 
2975
                if ($result !== false) {
1441 ariadna 2976
                    $result = $this->decodeContent($result, $mode, true, $prev, $formatted);
1 efrain 2977
                }
2978
            }
2979
            // response with string literal
2980
            else if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
2981
                $bytes = (int) $m[1];
2982
                $prev  = '';
2983
                $found = true;
2984
                $chunkSize = 1024 * 1024;
2985
 
2986
                // empty body
2987
                if (!$bytes) {
2988
                    $result = '';
2989
                }
2990
                // An optimal path for a case when we need the body as-is in a string
2991
                else if (!$mode && !$file && !$print) {
2992
                    $result = $this->readBytes($bytes);
2993
                }
2994
                else while ($bytes > 0) {
2995
                    $chunk = $this->readBytes($bytes > $chunkSize ? $chunkSize : $bytes);
2996
 
2997
                    if ($chunk === '') {
2998
                        break;
2999
                    }
3000
 
3001
                    $len = strlen($chunk);
3002
 
3003
                    if ($len > $bytes) {
3004
                        $chunk = substr($chunk, 0, $bytes);
3005
                        $len = strlen($chunk);
3006
                    }
3007
                    $bytes -= $len;
3008
 
1441 ariadna 3009
                    $chunk = $this->decodeContent($chunk, $mode, $bytes <= 0, $prev, $formatted);
1 efrain 3010
 
3011
                    if ($file) {
3012
                        if (fwrite($file, $chunk) === false) {
3013
                            break;
3014
                        }
3015
                    }
3016
                    else if ($print) {
3017
                        echo $chunk;
3018
                    }
3019
                    else {
3020
                        $result .= $chunk;
3021
                    }
3022
                }
3023
            }
3024
        }
3025
        while (!$this->startsWith($line, $key, true) || !$initiated);
3026
 
3027
        if ($result !== false) {
3028
            if ($file) {
3029
                return fwrite($file, $result);
3030
            }
3031
            else if ($print) {
3032
                echo $result;
3033
                return true;
3034
            }
3035
 
3036
            return $result;
3037
        }
3038
 
3039
        return false;
3040
    }
3041
 
3042
    /**
3043
     * Decodes a chunk of a message part content from a FETCH response.
3044
     *
1441 ariadna 3045
     * @param string $chunk     Content
3046
     * @param int    $mode      Encoding mode
3047
     * @param bool   $is_last   Whether it is a last chunk of data
3048
     * @param string $prev      Extra content from the previous chunk
3049
     * @param bool   $formatted Format the content for output
1 efrain 3050
     *
3051
     * @return string Encoded string
3052
     */
1441 ariadna 3053
    protected static function decodeContent($chunk, $mode, $is_last = false, &$prev = '', $formatted = false)
1 efrain 3054
    {
3055
        // BASE64
3056
        if ($mode == 1) {
3057
            $chunk = $prev . preg_replace('|[^a-zA-Z0-9+=/]|', '', $chunk);
3058
 
3059
            // create chunks with proper length for base64 decoding
3060
            $length = strlen($chunk);
3061
 
3062
            if ($length % 4) {
3063
                $length = floor($length / 4) * 4;
3064
                $prev = substr($chunk, $length);
3065
                $chunk = substr($chunk, 0, $length);
3066
            }
3067
            else {
3068
                $prev = '';
3069
            }
3070
 
1441 ariadna 3071
            // There might be multiple base64 blocks in a single message part,
3072
            // we have to pass them separately to base64_decode() (#9290)
3073
            $result = '';
3074
            foreach (preg_split('|=+|', $chunk, -1, \PREG_SPLIT_NO_EMPTY) as $_chunk) {
3075
                $result .= base64_decode($_chunk);
3076
            }
3077
 
3078
            $chunk = $result;
1 efrain 3079
        }
3080
        // QUOTED-PRINTABLE
1441 ariadna 3081
        elseif ($mode == 2) {
1 efrain 3082
            if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
3083
                return '';
3084
            }
3085
 
1441 ariadna 3086
            $chunk = quoted_printable_decode($chunk);
1 efrain 3087
        }
3088
        // X-UUENCODE
1441 ariadna 3089
        elseif ($mode == 3) {
1 efrain 3090
            if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
3091
                return '';
3092
            }
3093
 
3094
            $chunk = preg_replace(
3095
                ['/\r?\n/', '/(^|\n)end$/', '/^begin\s+[0-7]{3,4}\s+[^\n]+\n/'],
3096
                ["\n", '', ''],
3097
                $chunk
3098
            );
3099
 
3100
            if (!strlen($chunk)) {
3101
                return '';
3102
            }
3103
 
1441 ariadna 3104
            $chunk = convert_uudecode($chunk);
1 efrain 3105
        }
3106
        // Plain text formatted
3107
        // TODO: Formatting should be handled outside of this class
1441 ariadna 3108
        elseif ($mode == 4) {
1 efrain 3109
            if (!self::decodeContentChunk($chunk, $prev, $is_last)) {
3110
                return '';
3111
            }
3112
 
3113
            if ($is_last) {
3114
                $chunk = rtrim($chunk, "\t\r\n\0\x0B");
3115
            }
1441 ariadna 3116
        }
1 efrain 3117
 
1441 ariadna 3118
        if ($formatted) {
3119
            $chunk = preg_replace('/[\t\r\0\x0B]+\n/', "\n", $chunk);
1 efrain 3120
        }
3121
 
3122
        return $chunk;
3123
    }
3124
 
3125
    /**
3126
     * A helper for a new-line aware parsing. See self::decodeContent().
3127
     */
3128
    private static function decodeContentChunk(&$chunk, &$prev, $is_last)
3129
    {
3130
        $chunk = $prev . $chunk;
3131
        $prev = '';
3132
 
3133
        if (!$is_last) {
3134
            if (($pos = strrpos($chunk, "\n")) !== false) {
3135
                $prev = substr($chunk, $pos + 1);
3136
                $chunk = substr($chunk, 0, $pos + 1);
3137
            } else {
3138
                $prev = $chunk;
3139
                return false;
3140
            }
3141
        }
3142
 
3143
        return true;
3144
    }
3145
 
3146
    /**
3147
     * Handler for IMAP APPEND command
3148
     *
3149
     * @param string       $mailbox Mailbox name
3150
     * @param string|array $message The message source string or array (of strings and file pointers)
3151
     * @param array        $flags   Message flags
3152
     * @param string       $date    Message internal date
3153
     * @param bool         $binary  Enable BINARY append (RFC3516)
3154
     *
3155
     * @return string|bool On success APPENDUID response (if available) or True, False on failure
3156
     */
3157
    public function append($mailbox, &$message, $flags = [], $date = null, $binary = false)
3158
    {
3159
        unset($this->data['APPENDUID']);
3160
 
3161
        if ($mailbox === null || $mailbox === '') {
3162
            return false;
3163
        }
3164
 
3165
        $binary       = $binary && $this->getCapability('BINARY');
3166
        $literal_plus = !$binary && !empty($this->prefs['literal+']);
3167
        $len          = 0;
3168
        $msg          = is_array($message) ? $message : [&$message];
3169
        $chunk_size   = 512000;
3170
 
3171
        for ($i=0, $cnt=count($msg); $i<$cnt; $i++) {
3172
            if (is_resource($msg[$i])) {
3173
                $stat = fstat($msg[$i]);
3174
                if ($stat === false) {
3175
                    return false;
3176
                }
3177
                $len += $stat['size'];
3178
            }
3179
            else {
3180
                if (!$binary) {
3181
                    $msg[$i] = str_replace("\r", '', $msg[$i]);
3182
                    $msg[$i] = str_replace("\n", "\r\n", $msg[$i]);
3183
                }
3184
 
3185
                $len += strlen($msg[$i]);
3186
            }
3187
        }
3188
 
3189
        if (!$len) {
3190
            return false;
3191
        }
3192
 
3193
        // build APPEND command
3194
        $key = $this->nextTag();
3195
        $request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')';
3196
        if (!empty($date)) {
3197
            $request .= ' ' . $this->escape($date);
3198
        }
3199
        $request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}';
3200
 
3201
        // send APPEND command
3202
        if (!$this->putLine($request)) {
3203
            $this->setError(self::ERROR_COMMAND, "Failed to send APPEND command");
3204
            return false;
3205
        }
3206
 
3207
        // Do not wait when LITERAL+ is supported
3208
        if (!$literal_plus) {
3209
            $line = $this->readReply();
3210
 
3211
            if ($line[0] != '+') {
3212
                $this->parseResult($line, 'APPEND: ');
3213
                return false;
3214
            }
3215
        }
3216
 
3217
        foreach ($msg as $msg_part) {
3218
            // file pointer
3219
            if (is_resource($msg_part)) {
3220
                rewind($msg_part);
3221
                while (!feof($msg_part) && $this->fp) {
3222
                    $buffer = fread($msg_part, $chunk_size);
3223
                    $this->putLine($buffer, false);
3224
                }
3225
                fclose($msg_part);
3226
            }
3227
            // string
3228
            else {
3229
                $size = strlen($msg_part);
3230
 
3231
                // Break up the data by sending one chunk (up to 512k) at a time.
3232
                // This approach reduces our peak memory usage
3233
                for ($offset = 0; $offset < $size; $offset += $chunk_size) {
3234
                    $chunk = substr($msg_part, $offset, $chunk_size);
3235
                    if (!$this->putLine($chunk, false)) {
3236
                        return false;
3237
                    }
3238
                }
3239
            }
3240
        }
3241
 
3242
        if (!$this->putLine('')) { // \r\n
3243
            return false;
3244
        }
3245
 
3246
        do {
3247
            $line = $this->readLine();
3248
        } while (!$this->startsWith($line, $key, true, true));
3249
 
3250
        // Clear internal status cache
3251
        unset($this->data['STATUS:'.$mailbox]);
3252
 
3253
        if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) {
3254
            return false;
3255
        }
3256
 
3257
        if (!empty($this->data['APPENDUID'])) {
3258
            return $this->data['APPENDUID'];
3259
        }
3260
 
3261
        return true;
3262
    }
3263
 
3264
    /**
3265
     * Handler for IMAP APPEND command.
3266
     *
3267
     * @param string $mailbox Mailbox name
3268
     * @param string $path    Path to the file with message body
3269
     * @param string $headers Message headers
3270
     * @param array  $flags   Message flags
3271
     * @param string $date    Message internal date
3272
     * @param bool   $binary  Enable BINARY append (RFC3516)
3273
     *
3274
     * @return string|bool On success APPENDUID response (if available) or True, False on failure
3275
     */
3276
    public function appendFromFile($mailbox, $path, $headers = null, $flags = [], $date = null, $binary = false)
3277
    {
3278
        // open message file
3279
        if (file_exists(realpath($path))) {
3280
            $fp = fopen($path, 'r');
3281
        }
3282
 
3283
        if (empty($fp)) {
3284
            $this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading");
3285
            return false;
3286
        }
3287
 
3288
        $message = [];
3289
        if ($headers) {
3290
            $message[] = trim($headers, "\r\n") . "\r\n\r\n";
3291
        }
3292
        $message[] = $fp;
3293
 
3294
        return $this->append($mailbox, $message, $flags, $date, $binary);
3295
    }
3296
 
3297
    /**
3298
     * Returns QUOTA information
3299
     *
3300
     * @param string $mailbox Mailbox name
3301
     *
3302
     * @return array|false Quota information, False on error
3303
     */
3304
    public function getQuota($mailbox = null)
3305
    {
3306
        if ($mailbox === null || $mailbox === '') {
3307
            $mailbox = 'INBOX';
3308
        }
3309
 
3310
        // a0001 GETQUOTAROOT INBOX
3311
        // * QUOTAROOT INBOX user/sample
3312
        // * QUOTA user/sample (STORAGE 654 9765)
3313
        // a0001 OK Completed
3314
 
3315
        list($code, $response) = $this->execute('GETQUOTAROOT', [$this->escape($mailbox)], 0, '/^\* QUOTA /i');
3316
 
3317
        if ($code != self::ERROR_OK) {
3318
            return false;
3319
        }
3320
 
3321
        $min_free = PHP_INT_MAX;
3322
        $result   = [];
3323
        $all      = [];
3324
 
3325
        foreach (explode("\n", $response) as $line) {
3326
            $tokens     = $this->tokenizeResponse($line, 3);
3327
            $quota_root = $tokens[2] ?? null;
3328
            $quotas     = $this->tokenizeResponse($line, 1);
3329
 
3330
            if (empty($quotas)) {
3331
                continue;
3332
            }
3333
 
3334
            foreach (array_chunk($quotas, 3) as $quota) {
3335
                list($type, $used, $total) = $quota;
3336
                $type = strtolower($type);
3337
 
3338
                if ($type && $total) {
3339
                    $all[$quota_root][$type]['used']  = intval($used);
3340
                    $all[$quota_root][$type]['total'] = intval($total);
3341
                }
3342
            }
3343
 
3344
            if (empty($all[$quota_root]['storage'])) {
3345
                continue;
3346
            }
3347
 
3348
            $used  = $all[$quota_root]['storage']['used'];
3349
            $total = $all[$quota_root]['storage']['total'];
3350
            $free  = $total - $used;
3351
 
3352
            // calculate lowest available space from all storage quotas
3353
            if ($free < $min_free) {
3354
                $min_free          = $free;
3355
                $result['used']    = $used;
3356
                $result['total']   = $total;
3357
                $result['percent'] = min(100, round(($used/max(1,$total))*100));
3358
                $result['free']    = 100 - $result['percent'];
3359
            }
3360
        }
3361
 
3362
        if (!empty($result)) {
3363
            $result['all'] = $all;
3364
        }
3365
 
3366
        return $result;
3367
    }
3368
 
3369
    /**
3370
     * Send the SETACL command (RFC4314)
3371
     *
3372
     * @param string $mailbox Mailbox name
3373
     * @param string $user    User name
3374
     * @param mixed  $acl     ACL string or array
3375
     *
3376
     * @return bool True on success, False on failure
3377
     *
3378
     * @since 0.5-beta
3379
     */
3380
    public function setACL($mailbox, $user, $acl)
3381
    {
3382
        if (is_array($acl)) {
3383
            $acl = implode('', $acl);
3384
        }
3385
 
3386
        $result = $this->execute('SETACL',
3387
            [$this->escape($mailbox), $this->escape($user), strtolower($acl)],
3388
            self::COMMAND_NORESPONSE
3389
        );
3390
 
3391
        return $result == self::ERROR_OK;
3392
    }
3393
 
3394
    /**
3395
     * Send the DELETEACL command (RFC4314)
3396
     *
3397
     * @param string $mailbox Mailbox name
3398
     * @param string $user    User name
3399
     *
3400
     * @return bool True on success, False on failure
3401
     *
3402
     * @since 0.5-beta
3403
     */
3404
    public function deleteACL($mailbox, $user)
3405
    {
3406
        $result = $this->execute('DELETEACL',
3407
            [$this->escape($mailbox), $this->escape($user)],
3408
            self::COMMAND_NORESPONSE
3409
        );
3410
 
3411
        return $result == self::ERROR_OK;
3412
    }
3413
 
3414
    /**
3415
     * Send the GETACL command (RFC4314)
3416
     *
3417
     * @param string $mailbox Mailbox name
3418
     *
3419
     * @return array User-rights array on success, NULL on error
3420
     * @since 0.5-beta
3421
     */
3422
    public function getACL($mailbox)
3423
    {
3424
        list($code, $response) = $this->execute('GETACL', [$this->escape($mailbox)], 0, '/^\* ACL /i');
3425
 
3426
        if ($code == self::ERROR_OK && $response) {
3427
            // Parse server response (remove "* ACL ")
3428
            $response = substr($response, 6);
3429
            $ret  = $this->tokenizeResponse($response);
3430
            $mbox = array_shift($ret);
3431
            $size = count($ret);
3432
 
3433
            // Create user-rights hash array
3434
            // @TODO: consider implementing fixACL() method according to RFC4314.2.1.1
3435
            // so we could return only standard rights defined in RFC4314,
3436
            // excluding 'c' and 'd' defined in RFC2086.
3437
            if ($size % 2 == 0) {
3438
                for ($i=0; $i<$size; $i++) {
3439
                    $ret[$ret[$i]] = str_split($ret[++$i]);
3440
                    unset($ret[$i-1]);
3441
                    unset($ret[$i]);
3442
                }
3443
                return $ret;
3444
            }
3445
 
3446
            $this->setError(self::ERROR_COMMAND, "Incomplete ACL response");
3447
        }
3448
    }
3449
 
3450
    /**
3451
     * Send the LISTRIGHTS command (RFC4314)
3452
     *
3453
     * @param string $mailbox Mailbox name
3454
     * @param string $user    User name
3455
     *
3456
     * @return array List of user rights
3457
     * @since 0.5-beta
3458
     */
3459
    public function listRights($mailbox, $user)
3460
    {
3461
        list($code, $response) = $this->execute('LISTRIGHTS',
3462
            [$this->escape($mailbox), $this->escape($user)], 0, '/^\* LISTRIGHTS /i');
3463
 
3464
        if ($code == self::ERROR_OK && $response) {
3465
            // Parse server response (remove "* LISTRIGHTS ")
3466
            $response = substr($response, 13);
3467
 
3468
            $ret_mbox = $this->tokenizeResponse($response, 1);
3469
            $ret_user = $this->tokenizeResponse($response, 1);
3470
            $granted  = $this->tokenizeResponse($response, 1);
3471
            $optional = trim($response);
3472
 
3473
            return [
3474
                'granted'  => str_split($granted),
3475
                'optional' => explode(' ', $optional),
3476
            ];
3477
        }
3478
    }
3479
 
3480
    /**
3481
     * Send the MYRIGHTS command (RFC4314)
3482
     *
3483
     * @param string $mailbox Mailbox name
3484
     *
3485
     * @return array MYRIGHTS response on success, NULL on error
3486
     * @since 0.5-beta
3487
     */
3488
    public function myRights($mailbox)
3489
    {
3490
        list($code, $response) = $this->execute('MYRIGHTS', [$this->escape($mailbox)], 0, '/^\* MYRIGHTS /i');
3491
 
3492
        if ($code == self::ERROR_OK && $response) {
3493
            // Parse server response (remove "* MYRIGHTS ")
3494
            $response = substr($response, 11);
3495
 
3496
            $ret_mbox = $this->tokenizeResponse($response, 1);
3497
            $rights   = $this->tokenizeResponse($response, 1);
3498
 
3499
            return str_split($rights);
3500
        }
3501
    }
3502
 
3503
    /**
3504
     * Send the SETMETADATA command (RFC5464)
3505
     *
3506
     * @param string $mailbox Mailbox name
3507
     * @param array  $entries Entry-value array (use NULL value as NIL)
3508
     *
3509
     * @return bool True on success, False on failure
3510
     * @since 0.5-beta
3511
     */
3512
    public function setMetadata($mailbox, $entries)
3513
    {
3514
        if (!is_array($entries) || empty($entries)) {
3515
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
3516
            return false;
3517
        }
3518
 
3519
        foreach ($entries as $name => $value) {
3520
            $entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true);
3521
        }
3522
 
3523
        $entries = implode(' ', $entries);
3524
        $result = $this->execute('SETMETADATA',
3525
            [$this->escape($mailbox), '(' . $entries . ')'],
3526
            self::COMMAND_NORESPONSE
3527
        );
3528
 
3529
        return $result == self::ERROR_OK;
3530
    }
3531
 
3532
    /**
3533
     * Send the SETMETADATA command with NIL values (RFC5464)
3534
     *
3535
     * @param string $mailbox Mailbox name
3536
     * @param array  $entries Entry names array
3537
     *
3538
     * @return bool True on success, False on failure
3539
     *
3540
     * @since 0.5-beta
3541
     */
3542
    public function deleteMetadata($mailbox, $entries)
3543
    {
3544
        if (!is_array($entries) && !empty($entries)) {
3545
            $entries = explode(' ', $entries);
3546
        }
3547
 
3548
        if (empty($entries)) {
3549
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
3550
            return false;
3551
        }
3552
 
3553
        $data = [];
3554
        foreach ($entries as $entry) {
3555
            $data[$entry] = null;
3556
        }
3557
 
3558
        return $this->setMetadata($mailbox, $data);
3559
    }
3560
 
3561
    /**
3562
     * Send the GETMETADATA command (RFC5464)
3563
     *
3564
     * @param string $mailbox Mailbox name
3565
     * @param array  $entries Entries
3566
     * @param array  $options Command options (with MAXSIZE and DEPTH keys)
3567
     *
3568
     * @return array GETMETADATA result on success, NULL on error
3569
     *
3570
     * @since 0.5-beta
3571
     */
3572
    public function getMetadata($mailbox, $entries, $options = [])
3573
    {
3574
        if (!is_array($entries)) {
3575
            $entries = [$entries];
3576
        }
3577
 
3578
        $args = [];
3579
 
3580
        // create options string
3581
        if (is_array($options)) {
3582
            $options = array_change_key_case($options, CASE_UPPER);
3583
            $opts    = [];
3584
 
3585
            if (!empty($options['MAXSIZE'])) {
3586
                $opts[] = 'MAXSIZE '.intval($options['MAXSIZE']);
3587
            }
3588
 
3589
            if (isset($options['DEPTH'])) {
3590
                $opts[] = 'DEPTH ' . $this->escape($options['DEPTH']);
3591
            }
3592
 
3593
            if (!empty($opts)) {
3594
                $args[] = $opts;
3595
            }
3596
        }
3597
 
3598
        $args[] = $this->escape($mailbox);
3599
        $args[] = array_map([$this, 'escape'], $entries);
3600
 
3601
        list($code, $response) = $this->execute('GETMETADATA', $args);
3602
 
3603
        if ($code == self::ERROR_OK) {
3604
            $result = [];
3605
            $data   = $this->tokenizeResponse($response);
3606
 
3607
            // The METADATA response can contain multiple entries in a single
3608
            // response or multiple responses for each entry or group of entries
3609
            for ($i = 0, $size = count($data); $i < $size; $i++) {
3610
                if ($data[$i] === '*'
3611
                    && $data[++$i] === 'METADATA'
3612
                    && is_string($mbox = $data[++$i])
3613
                    && is_array($data[++$i])
3614
                ) {
3615
                    for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) {
3616
                        if ($data[$i][$x+1] !== null) {
3617
                            $result[$mbox][$data[$i][$x]] = $data[$i][$x+1];
3618
                        }
3619
                    }
3620
                }
3621
            }
3622
 
3623
            return $result;
3624
        }
3625
    }
3626
 
3627
    /**
3628
     * Send the SETANNOTATION command (draft-daboo-imap-annotatemore)
3629
     *
3630
     * @param string $mailbox Mailbox name
3631
     * @param array  $data    Data array where each item is an array with
3632
     *                        three elements: entry name, attribute name, value
3633
     *
3634
     * @return bool True on success, False on failure
3635
     * @since 0.5-beta
3636
     */
3637
    public function setAnnotation($mailbox, $data)
3638
    {
3639
        if (!is_array($data) || empty($data)) {
3640
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
3641
            return false;
3642
        }
3643
 
3644
        foreach ($data as $entry) {
3645
            // ANNOTATEMORE drafts before version 08 require quoted parameters
3646
            $entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true),
3647
                $this->escape($entry[1], true), $this->escape($entry[2], true));
3648
        }
3649
 
3650
        $entries = implode(' ', $entries);
3651
        $result  = $this->execute('SETANNOTATION', [$this->escape($mailbox), $entries], self::COMMAND_NORESPONSE);
3652
 
3653
        return $result == self::ERROR_OK;
3654
    }
3655
 
3656
    /**
3657
     * Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore)
3658
     *
3659
     * @param string $mailbox Mailbox name
3660
     * @param array  $data    Data array where each item is an array with
3661
     *                        two elements: entry name and attribute name
3662
     *
3663
     * @return bool True on success, False on failure
3664
     *
3665
     * @since 0.5-beta
3666
     */
3667
    public function deleteAnnotation($mailbox, $data)
3668
    {
3669
        if (!is_array($data) || empty($data)) {
3670
            $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
3671
            return false;
3672
        }
3673
 
3674
        return $this->setAnnotation($mailbox, $data);
3675
    }
3676
 
3677
    /**
3678
     * Send the GETANNOTATION command (draft-daboo-imap-annotatemore)
3679
     *
3680
     * @param string $mailbox Mailbox name
3681
     * @param array  $entries Entries names
3682
     * @param array  $attribs Attribs names
3683
     *
3684
     * @return array Annotations result on success, NULL on error
3685
     *
3686
     * @since 0.5-beta
3687
     */
3688
    public function getAnnotation($mailbox, $entries, $attribs)
3689
    {
3690
        if (!is_array($entries)) {
3691
            $entries = [$entries];
3692
        }
3693
 
3694
        // create entries string
3695
        // ANNOTATEMORE drafts before version 08 require quoted parameters
3696
        foreach ($entries as $idx => $name) {
3697
            $entries[$idx] = $this->escape($name, true);
3698
        }
3699
        $entries = '(' . implode(' ', $entries) . ')';
3700
 
3701
        if (!is_array($attribs)) {
3702
            $attribs = [$attribs];
3703
        }
3704
 
3705
        // create attributes string
3706
        foreach ($attribs as $idx => $name) {
3707
            $attribs[$idx] = $this->escape($name, true);
3708
        }
3709
        $attribs = '(' . implode(' ', $attribs) . ')';
3710
 
3711
        list($code, $response) = $this->execute('GETANNOTATION', [$this->escape($mailbox), $entries, $attribs]);
3712
 
3713
        if ($code == self::ERROR_OK) {
3714
            $result     = [];
3715
            $data       = $this->tokenizeResponse($response);
3716
            $last_entry = null;
3717
 
3718
            // Here we returns only data compatible with METADATA result format
3719
            if (!empty($data) && ($size = count($data))) {
3720
                for ($i=0; $i<$size; $i++) {
3721
                    $entry = $data[$i];
3722
                    if (isset($mbox) && is_array($entry)) {
3723
                        $attribs = $entry;
3724
                        $entry   = $last_entry;
3725
                    }
3726
                    else if ($entry == '*') {
3727
                        if ($data[$i+1] == 'ANNOTATION') {
3728
                            $mbox = $data[$i+2];
3729
                            unset($data[$i]);   // "*"
3730
                            unset($data[++$i]); // "ANNOTATION"
3731
                            unset($data[++$i]); // Mailbox
3732
                        }
3733
                        // get rid of other untagged responses
3734
                        else {
3735
                            unset($mbox);
3736
                            unset($data[$i]);
3737
                        }
3738
                        continue;
3739
                    }
3740
                    else if (isset($mbox)) {
3741
                        $attribs = $data[++$i];
3742
                    }
3743
                    else {
3744
                        unset($data[$i]);
3745
                        continue;
3746
                    }
3747
 
3748
                    if (!empty($attribs)) {
3749
                        for ($x=0, $len=count($attribs); $x<$len;) {
3750
                            $attr  = $attribs[$x++];
3751
                            $value = $attribs[$x++];
3752
                            if ($attr == 'value.priv' && $value !== null) {
3753
                                $result[$mbox]['/private' . $entry] = $value;
3754
                            }
3755
                            else if ($attr == 'value.shared' && $value !== null) {
3756
                                $result[$mbox]['/shared' . $entry] = $value;
3757
                            }
3758
                        }
3759
                    }
3760
 
3761
                    $last_entry = $entry;
3762
                    unset($data[$i]);
3763
                }
3764
            }
3765
 
3766
            return $result;
3767
        }
3768
    }
3769
 
3770
    /**
1441 ariadna 3771
     * Send the STORE X ANNOTATION command (RFC5257)
3772
     *
3773
     * @param string $mailbox Mailbox name
3774
     * @param array  $entries
3775
     *
3776
     * @return bool True on success, False on failure
3777
     *
3778
     * @since 1.6.10
3779
     */
3780
    public function storeMessageAnnotation($mailbox, $uids, $entries)
3781
    {
3782
        if (!$this->hasCapability('ANNOTATE-EXPERIMENT-1')) {
3783
            return false;
3784
        }
3785
 
3786
        if (empty($entries) || empty($uids)) {
3787
            $this->setError(self::ERROR_COMMAND, 'Wrong argument for STORE ANNOTATION command');
3788
            return false;
3789
        }
3790
 
3791
        if (!$this->select($mailbox)) {
3792
            return false;
3793
        }
3794
 
3795
        /* Example input compatible with rcube_message_header::$annotations:
3796
           $entries = [
3797
               '/comment' => [
3798
                   'value.priv' => 'test1',
3799
                   'value.shared' => null,
3800
               ],
3801
           ];
3802
        */
3803
 
3804
        $request = [];
3805
        foreach ($entries as $name => $annotation) {
3806
            if (!empty($annotation)) {
3807
                foreach ($annotation as $key => $value) {
3808
                    $annotation[$key] = $this->escape($key) . ' ' . $this->escape($value, true);
3809
                }
3810
                $request[] = $this->escape($name);
3811
                $request[] = $annotation;
3812
            }
3813
        }
3814
 
3815
        $result = $this->execute(
3816
            'UID STORE',
3817
            [$this->compressMessageSet($uids), 'ANNOTATION', $request],
3818
            self::COMMAND_NORESPONSE
3819
        );
3820
 
3821
        return $result == self::ERROR_OK;
3822
    }
3823
 
3824
    /**
1 efrain 3825
     * Returns BODYSTRUCTURE for the specified message.
3826
     *
3827
     * @param string $mailbox Folder name
3828
     * @param int    $id      Message sequence number or UID
3829
     * @param bool   $is_uid  True if $id is an UID
3830
     *
3831
     * @return array|bool Body structure array or False on error.
3832
     * @since 0.6
3833
     */
3834
    public function getStructure($mailbox, $id, $is_uid = false)
3835
    {
3836
        $result = $this->fetch($mailbox, $id, $is_uid, ['BODYSTRUCTURE']);
3837
 
3838
        if (is_array($result) && !empty($result)) {
3839
            $result = array_shift($result);
3840
            return $result->bodystructure;
3841
        }
3842
 
3843
        return false;
3844
    }
3845
 
3846
    /**
3847
     * Returns data of a message part according to specified structure.
3848
     *
3849
     * @param array  $structure Message structure (getStructure() result)
3850
     * @param string $part      Message part identifier
3851
     *
3852
     * @return array Part data as hash array (type, encoding, charset, size)
3853
     */
3854
    public static function getStructurePartData($structure, $part)
3855
    {
3856
        $part_a = self::getStructurePartArray($structure, $part);
3857
        $data   = [];
3858
 
3859
        if (empty($part_a)) {
3860
            return $data;
3861
        }
3862
 
3863
        // content-type
3864
        if (is_array($part_a[0])) {
3865
            $data['type'] = 'multipart';
3866
        }
3867
        else {
3868
            $data['type']     = strtolower($part_a[0]);
3869
            $data['subtype']  = strtolower($part_a[1]);
3870
            $data['encoding'] = strtolower($part_a[5]);
3871
 
3872
            // charset
3873
            if (is_array($part_a[2])) {
3874
               foreach ($part_a[2] as $key => $val) {
3875
                    if (strcasecmp($val, 'charset') == 0) {
3876
                        $data['charset'] = $part_a[2][$key+1];
3877
                        break;
3878
                    }
3879
                }
3880
            }
3881
        }
3882
 
3883
        // size
3884
        $data['size'] = intval($part_a[6]);
3885
 
3886
        return $data;
3887
    }
3888
 
3889
    public static function getStructurePartArray($a, $part)
3890
    {
3891
        if (!is_array($a)) {
3892
            return false;
3893
        }
3894
 
3895
        if (empty($part)) {
3896
            return $a;
3897
        }
3898
 
3899
        $ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : '';
3900
 
3901
        if (strcasecmp($ctype, 'message/rfc822') == 0) {
3902
            $a = $a[8];
3903
        }
3904
 
3905
        if (strpos($part, '.') > 0) {
3906
            $orig_part = $part;
3907
            $pos       = strpos($part, '.');
3908
            $rest      = substr($orig_part, $pos+1);
3909
            $part      = substr($orig_part, 0, $pos);
3910
 
3911
            return self::getStructurePartArray($a[$part-1], $rest);
3912
        }
3913
        else if ($part > 0) {
3914
            return is_array($a[$part-1]) ? $a[$part-1] : $a;
3915
        }
3916
    }
3917
 
3918
    /**
3919
     * Creates next command identifier (tag)
3920
     *
3921
     * @return string Command identifier
3922
     * @since 0.5-beta
3923
     */
3924
    public function nextTag()
3925
    {
3926
        $this->cmd_num++;
3927
        $this->cmd_tag = sprintf('A%04d', $this->cmd_num);
3928
 
3929
        return $this->cmd_tag;
3930
    }
3931
 
3932
    /**
3933
     * Sends IMAP command and parses result
3934
     *
3935
     * @param string $command   IMAP command
3936
     * @param array  $arguments Command arguments
3937
     * @param int    $options   Execution options
3938
     * @param string $filter    Line filter (regexp)
3939
     *
3940
     * @return mixed Response code or list of response code and data
3941
     * @since 0.5-beta
3942
     */
3943
    public function execute($command, $arguments = [], $options = 0, $filter = null)
3944
    {
3945
        $tag      = $this->nextTag();
3946
        $query    = $tag . ' ' . $command;
3947
        $noresp   = ($options & self::COMMAND_NORESPONSE);
3948
        $response = $noresp ? null : '';
3949
 
3950
        if (!empty($arguments)) {
3951
            foreach ($arguments as $arg) {
3952
                $query .= ' ' . self::r_implode($arg);
3953
            }
3954
        }
3955
 
3956
        // Send command
3957
        if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) {
3958
            preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches);
3959
            $cmd = $matches[1] ?: 'UNKNOWN';
3960
            $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
3961
 
3962
            return $noresp ? self::ERROR_COMMAND : [self::ERROR_COMMAND, ''];
3963
        }
3964
 
3965
        // Parse response
3966
        do {
3967
            $line = $this->readFullLine(4096);
3968
 
3969
            if ($response !== null) {
3970
                if (!$filter || preg_match($filter, $line)) {
3971
                    $response .= $line;
3972
                }
3973
            }
3974
 
3975
            // parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851)
3976
            if ($line && $command == 'UID MOVE') {
3977
                if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) {
3978
                    $this->data['COPYUID'] = [$m[1], $m[2]];
3979
                }
3980
            }
3981
        }
3982
        while (!$this->startsWith($line, $tag . ' ', true, true));
3983
 
3984
        $code = $this->parseResult($line, $command . ': ');
3985
 
3986
        // Remove last line from response
3987
        if ($response) {
3988
            if (!$filter) {
3989
                $line_len = min(strlen($response), strlen($line));
3990
                $response = substr($response, 0, -$line_len);
3991
            }
3992
 
3993
            $response = rtrim($response, "\r\n");
3994
        }
3995
 
3996
        // optional CAPABILITY response
3997
        if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK
3998
            && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)
3999
        ) {
4000
            $this->parseCapability($matches[1], true);
4001
        }
4002
 
4003
        // return last line only (without command tag, result and response code)
4004
        if ($line && ($options & self::COMMAND_LASTLINE)) {
4005
            $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line));
4006
        }
4007
 
4008
        return $noresp ? $code : [$code, $response];
4009
    }
4010
 
4011
    /**
4012
     * Splits IMAP response into string tokens
4013
     *
4014
     * @param string &$str The IMAP's server response
4015
     * @param int    $num  Number of tokens to return
4016
     *
4017
     * @return mixed Tokens array or string if $num=1
4018
     * @since 0.5-beta
4019
     */
4020
    public static function tokenizeResponse(&$str, $num=0)
4021
    {
4022
        $result = [];
4023
 
4024
        while (!$num || count($result) < $num) {
4025
            // remove spaces from the beginning of the string
4026
            $str = ltrim($str);
4027
 
4028
            // empty string
4029
            if ($str === '' || $str === null) {
4030
                break;
4031
            }
4032
 
4033
            switch ($str[0]) {
4034
 
4035
            // String literal
4036
            case '{':
4037
                if (($epos = strpos($str, "}\r\n", 1)) == false) {
4038
                    // error
4039
                }
4040
                if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) {
4041
                    // error
4042
                }
4043
 
4044
                $result[] = $bytes ? substr($str, $epos + 3, $bytes) : '';
4045
                $str      = substr($str, $epos + 3 + $bytes);
4046
                break;
4047
 
4048
            // Quoted string
4049
            case '"':
4050
                $len = strlen($str);
4051
 
4052
                for ($pos=1; $pos<$len; $pos++) {
4053
                    if ($str[$pos] == '"') {
4054
                        break;
4055
                    }
4056
                    if ($str[$pos] == "\\") {
4057
                        if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
4058
                            $pos++;
4059
                        }
4060
                    }
4061
                }
4062
 
4063
                // we need to strip slashes for a quoted string
4064
                $result[] = stripslashes(substr($str, 1, $pos - 1));
4065
                $str      = substr($str, $pos + 1);
4066
                break;
4067
 
4068
            // Parenthesized list
4069
            case '(':
4070
                $str      = substr($str, 1);
4071
                $result[] = self::tokenizeResponse($str);
4072
                break;
4073
 
4074
            case ')':
4075
                $str = substr($str, 1);
4076
                return $result;
4077
 
4078
            // String atom, number, astring, NIL, *, %
4079
            default:
4080
                // excluded chars: SP, CTL, ), DEL
4081
                // we do not exclude [ and ] (#1489223)
4082
                if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) {
4083
                    $result[] = $m[1] == 'NIL' ? null : $m[1];
4084
                    $str      = substr($str, strlen($m[1]));
4085
                }
4086
 
4087
                break;
4088
            }
4089
        }
4090
 
4091
        return $num == 1 ? ($result[0] ?? '') : $result;
4092
    }
4093
 
4094
    /**
4095
     * Joins IMAP command line elements (recursively)
4096
     */
4097
    protected static function r_implode($element)
4098
    {
4099
        if (!is_array($element)) {
4100
            return $element;
4101
        }
4102
 
4103
        reset($element);
4104
 
4105
        $string = '';
4106
 
4107
        foreach ($element as $value) {
4108
            $string .= ' ' . self::r_implode($value);
4109
        }
4110
 
4111
        return '(' . trim($string) . ')';
4112
    }
4113
 
4114
    /**
4115
     * Converts message identifiers array into sequence-set syntax
4116
     *
4117
     * @param array $messages Message identifiers
4118
     * @param bool  $force    Forces compression of any size
4119
     *
4120
     * @return string Compressed sequence-set
4121
     */
4122
    public static function compressMessageSet($messages, $force = false)
4123
    {
4124
        // given a comma delimited list of independent mid's,
4125
        // compresses by grouping sequences together
4126
        if (!is_array($messages)) {
4127
            // if less than 255 bytes long, let's not bother
4128
            if (!$force && strlen($messages) < 255) {
4129
                return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
4130
            }
4131
 
4132
            // see if it's already been compressed
4133
            if (strpos($messages, ':') !== false) {
4134
                return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
4135
            }
4136
 
4137
            // separate, then sort
4138
            $messages = explode(',', $messages);
4139
        }
4140
 
4141
        sort($messages);
4142
 
4143
        $result = [];
4144
        $start  = $prev = $messages[0];
4145
 
4146
        foreach ($messages as $id) {
4147
            $incr = $id - $prev;
4148
            if ($incr > 1) { // found a gap
4149
                if ($start == $prev) {
4150
                    $result[] = $prev; // push single id
4151
                }
4152
                else {
4153
                    $result[] = $start . ':' . $prev; // push sequence as start_id:end_id
4154
                }
4155
                $start = $id; // start of new sequence
4156
            }
4157
            $prev = $id;
4158
        }
4159
 
4160
        // handle the last sequence/id
4161
        if ($start == $prev) {
4162
            $result[] = $prev;
4163
        }
4164
        else {
4165
            $result[] = $start.':'.$prev;
4166
        }
4167
 
4168
        // return as comma separated string
4169
        $result = implode(',', $result);
4170
 
4171
        return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result;
4172
    }
4173
 
4174
    /**
4175
     * Converts message sequence-set into array
4176
     *
4177
     * @param string $messages Message identifiers
4178
     *
4179
     * @return array List of message identifiers
4180
     */
4181
    public static function uncompressMessageSet($messages)
4182
    {
4183
        if (empty($messages)) {
4184
            return [];
4185
        }
4186
 
4187
        $result   = [];
4188
        $messages = explode(',', $messages);
4189
 
4190
        foreach ($messages as $idx => $part) {
4191
            $items = explode(':', $part);
4192
 
4193
            if (!empty($items[1]) && $items[1] > $items[0]) {
4194
                $max = $items[1];
4195
            }
4196
            else {
4197
                $max = $items[0];
4198
            }
4199
 
4200
            for ($x = $items[0]; $x <= $max; $x++) {
4201
                $result[] = (int) $x;
4202
            }
4203
 
4204
            unset($messages[$idx]);
4205
        }
4206
 
4207
        return $result;
4208
    }
4209
 
4210
    /**
4211
     * Clear internal status cache
4212
     */
4213
    protected function clear_status_cache($mailbox)
4214
    {
4215
        unset($this->data['STATUS:' . $mailbox]);
4216
 
4217
        $keys = ['EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP'];
4218
 
4219
        foreach ($keys as $key) {
4220
            unset($this->data[$key]);
4221
        }
4222
    }
4223
 
4224
    /**
4225
     * Clear internal cache of the current mailbox
4226
     */
4227
    protected function clear_mailbox_cache()
4228
    {
4229
        $this->clear_status_cache($this->selected);
4230
 
4231
        $keys = ['UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ',
4232
            'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE'];
4233
 
4234
        foreach ($keys as $key) {
4235
            unset($this->data[$key]);
4236
        }
4237
    }
4238
 
4239
    /**
4240
     * Converts flags array into string for inclusion in IMAP command
4241
     *
4242
     * @param array $flags Flags (see self::flags)
4243
     *
4244
     * @return string Space-separated list of flags
4245
     */
4246
    protected function flagsToStr($flags)
4247
    {
4248
        foreach ((array) $flags as $idx => $flag) {
4249
            if ($flag = $this->flags[strtoupper($flag)]) {
4250
                $flags[$idx] = $flag;
4251
            }
4252
        }
4253
 
4254
        return implode(' ', (array) $flags);
4255
    }
4256
 
4257
    /**
4258
     * CAPABILITY response parser
4259
     */
4260
    protected function parseCapability($str, $trusted=false)
4261
    {
4262
        $str = preg_replace('/^\* CAPABILITY /i', '', $str);
4263
 
4264
        $this->capability = explode(' ', strtoupper($str));
4265
 
4266
        if (!empty($this->prefs['disabled_caps'])) {
4267
            $this->capability = array_diff($this->capability, $this->prefs['disabled_caps']);
4268
        }
4269
 
4270
        if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) {
4271
            $this->prefs['literal+'] = true;
4272
        }
4273
        else if (!isset($this->prefs['literal-']) && in_array('LITERAL-', $this->capability)) {
4274
            $this->prefs['literal-'] = true;
4275
        }
4276
 
4277
        if ($trusted) {
4278
            $this->capability_read = true;
4279
        }
4280
    }
4281
 
4282
    /**
4283
     * Escapes a string when it contains special characters (RFC3501)
4284
     *
4285
     * @param string $string       IMAP string
4286
     * @param bool   $force_quotes Forces string quoting (for atoms)
4287
     *
4288
     * @return string String atom, quoted-string or string literal
4289
     * @todo lists
4290
     */
4291
    public static function escape($string, $force_quotes = false)
4292
    {
4293
        if ($string === null) {
4294
            return 'NIL';
4295
        }
4296
 
4297
        if ($string === '') {
4298
            return '""';
4299
        }
4300
 
4301
        // atom-string (only safe characters)
4302
        if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) {
4303
            return $string;
4304
        }
4305
 
4306
        // quoted-string
4307
        if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
4308
            return '"' . addcslashes($string, '\\"') . '"';
4309
        }
4310
 
4311
        // literal-string
4312
        return sprintf("{%d}\r\n%s", strlen($string), $string);
4313
    }
4314
 
4315
    /**
4316
     * Set the value of the debugging flag.
4317
     *
4318
     * @param bool     $debug   New value for the debugging flag.
4319
     * @param callable $handler Logging handler function
4320
     *
4321
     * @since 0.5-stable
4322
     */
4323
    public function setDebug($debug, $handler = null)
4324
    {
4325
        $this->debug         = $debug;
4326
        $this->debug_handler = $handler;
4327
    }
4328
 
4329
    /**
4330
     * Write the given debug text to the current debug output handler.
4331
     *
4332
     * @param string $message Debug message text.
4333
     *
4334
     * @since 0.5-stable
4335
     */
4336
    protected function debug($message)
4337
    {
4338
        if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) {
4339
            $diff    = $len - self::DEBUG_LINE_LENGTH;
4340
            $message = substr($message, 0, self::DEBUG_LINE_LENGTH)
4341
                . "... [truncated $diff bytes]";
4342
        }
4343
 
4344
        if ($this->resourceid) {
4345
            $message = sprintf('[%s] %s', $this->resourceid, $message);
4346
        }
4347
 
4348
        if ($this->debug_handler) {
4349
            call_user_func_array($this->debug_handler, [$this, $message]);
4350
        }
4351
        else {
4352
            echo "DEBUG: $message\n";
4353
        }
4354
    }
4355
}