Passed
Push — master ( 3d516a...4dc410 )
by Malte
03:02
created

ImapProtocol::getUid()   B

Complexity

Conditions 9
Paths 24

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 16
c 1
b 0
f 0
dl 0
loc 28
rs 8.0555
cc 9
nc 24
nop 1
1
<?php
2
/*
3
* File: ImapProtocol.php
4
* Category: Protocol
5
* Author: M.Goldenbaum
6
* Created: 16.09.20 18:27
7
* Updated: -
8
*
9
* Description:
10
*  -
11
*/
12
13
namespace Webklex\PHPIMAP\Connection\Protocols;
14
15
use Exception;
16
use Webklex\PHPIMAP\Exceptions\AuthFailedException;
17
use Webklex\PHPIMAP\Exceptions\ConnectionFailedException;
18
use Webklex\PHPIMAP\Exceptions\InvalidMessageDateException;
19
use Webklex\PHPIMAP\Exceptions\MessageNotFoundException;
20
use Webklex\PHPIMAP\Exceptions\RuntimeException;
21
use Webklex\PHPIMAP\Header;
22
use Webklex\PHPIMAP\IMAP;
23
24
/**
25
 * Class ImapProtocol
26
 *
27
 * @package Webklex\PHPIMAP\Connection\Protocols
28
 */
29
class ImapProtocol extends Protocol {
30
31
    /**
32
     * Request noun
33
     * @var int
34
     */
35
    protected $noun = 0;
36
37
    /**
38
     * Imap constructor.
39
     * @param bool $cert_validation set to false to skip SSL certificate validation
40
     * @param mixed $encryption Connection encryption method
41
     */
42
    public function __construct($cert_validation = true, $encryption = false) {
43
        $this->setCertValidation($cert_validation);
44
        $this->encryption = $encryption;
45
    }
46
47
    /**
48
     * Public destructor
49
     */
50
    public function __destruct() {
51
        $this->logout();
52
    }
53
54
    /**
55
     * Open connection to IMAP server
56
     * @param string $host hostname or IP address of IMAP server
57
     * @param int|null $port of IMAP server, default is 143 and 993 for ssl
58
     *
59
     * @throws ConnectionFailedException
60
     */
61
    public function connect($host, $port = null) {
62
        $transport = 'tcp';
63
        $encryption = '';
64
65
        if ($this->encryption) {
66
            $encryption = strtolower($this->encryption);
67
            if (in_array($encryption, ['ssl', 'tls'])) {
68
                $transport = $encryption;
69
                $port = $port === null ? 993 : $port;
70
            }
71
        }
72
        $port = $port === null ? 143 : $port;
73
        try {
74
            $this->stream = $this->createStream($transport, $host, $port, $this->connection_timeout);
75
            if (!$this->assumedNextLine('* OK')) {
76
                throw new ConnectionFailedException('connection refused');
77
            }
78
            if ($encryption == 'starttls') {
79
                $this->enableStartTls();
80
            }
81
        } catch (Exception $e) {
82
            throw new ConnectionFailedException('connection failed', 0, $e);
83
        }
84
    }
85
86
    /**
87
     * Enable tls on the current connection
88
     *
89
     * @throws ConnectionFailedException
90
     * @throws RuntimeException
91
     */
92
    protected function enableStartTls(){
93
        $response = $this->requestAndResponse('STARTTLS');
94
        $result = $response && stream_socket_enable_crypto($this->stream, true, $this->getCryptoMethod());
0 ignored issues
show
Bug introduced by
It seems like $this->stream can also be of type boolean; however, parameter $stream of stream_socket_enable_crypto() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

94
        $result = $response && stream_socket_enable_crypto(/** @scrutinizer ignore-type */ $this->stream, true, $this->getCryptoMethod());
Loading history...
95
        if (!$result) {
96
            throw new ConnectionFailedException('failed to enable TLS');
97
        }
98
    }
99
100
    /**
101
     * Get the next line from stream
102
     *
103
     * @return string next line
104
     * @throws RuntimeException
105
     */
106
    public function nextLine() {
107
        $line = fgets($this->stream);
0 ignored issues
show
Bug introduced by
It seems like $this->stream can also be of type boolean; however, parameter $stream of fgets() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

107
        $line = fgets(/** @scrutinizer ignore-type */ $this->stream);
Loading history...
108
109
        if ($line === false) {
110
            throw new RuntimeException('failed to read - connection closed?');
111
        }
112
113
        return $line;
114
    }
115
116
    /**
117
     * Get the next line and check if it starts with a given string
118
     * @param string $start
119
     *
120
     * @return bool
121
     * @throws RuntimeException
122
     */
123
    protected function assumedNextLine($start) {
124
        $line = $this->nextLine();
125
        return strpos($line, $start) === 0;
126
    }
127
128
    /**
129
     * Get the next line and split the tag
130
     * @param string $tag reference tag
131
     *
132
     * @return string next line
133
     * @throws RuntimeException
134
     */
135
    protected function nextTaggedLine(&$tag) {
136
        $line = $this->nextLine();
137
        list($tag, $line) = explode(' ', $line, 2);
138
139
        return $line;
140
    }
141
142
    /**
143
     * Split a given line in values. A value is literal of any form or a list
144
     * @param string $line
145
     *
146
     * @return array
147
     * @throws RuntimeException
148
     */
149
    protected function decodeLine($line) {
150
        $tokens = [];
151
        $stack = [];
152
153
        //  replace any trailing <NL> including spaces with a single space
154
        $line = rtrim($line) . ' ';
155
        while (($pos = strpos($line, ' ')) !== false) {
156
            $token = substr($line, 0, $pos);
157
            if (!strlen($token)) {
158
                continue;
159
            }
160
            while ($token[0] == '(') {
161
                array_push($stack, $tokens);
162
                $tokens = [];
163
                $token = substr($token, 1);
164
            }
165
            if ($token[0] == '"') {
166
                if (preg_match('%^\(*"((.|\\\\|\\")*?)" *%', $line, $matches)) {
167
                    $tokens[] = $matches[1];
168
                    $line = substr($line, strlen($matches[0]));
169
                    continue;
170
                }
171
            }
172
            if ($token[0] == '{') {
173
                $endPos = strpos($token, '}');
174
                $chars = substr($token, 1, $endPos - 1);
175
                if (is_numeric($chars)) {
176
                    $token = '';
177
                    while (strlen($token) < $chars) {
178
                        $token .= $this->nextLine();
179
                    }
180
                    $line = '';
181
                    if (strlen($token) > $chars) {
182
                        $line = substr($token, $chars);
0 ignored issues
show
Bug introduced by
$chars of type string is incompatible with the type integer expected by parameter $offset of substr(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

182
                        $line = substr($token, /** @scrutinizer ignore-type */ $chars);
Loading history...
183
                        $token = substr($token, 0, $chars);
0 ignored issues
show
Bug introduced by
$chars of type string is incompatible with the type integer|null expected by parameter $length of substr(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

183
                        $token = substr($token, 0, /** @scrutinizer ignore-type */ $chars);
Loading history...
184
                    } else {
185
                        $line .= $this->nextLine();
186
                    }
187
                    $tokens[] = $token;
188
                    $line = trim($line) . ' ';
189
                    continue;
190
                }
191
            }
192
            if ($stack && $token[strlen($token) - 1] == ')') {
193
                // closing braces are not separated by spaces, so we need to count them
194
                $braces = strlen($token);
195
                $token = rtrim($token, ')');
196
                // only count braces if more than one
197
                $braces -= strlen($token) + 1;
198
                // only add if token had more than just closing braces
199
                if (rtrim($token) != '') {
200
                    $tokens[] = rtrim($token);
201
                }
202
                $token = $tokens;
203
                $tokens = array_pop($stack);
204
                // special handline if more than one closing brace
205
                while ($braces-- > 0) {
206
                    $tokens[] = $token;
207
                    $token = $tokens;
208
                    $tokens = array_pop($stack);
209
                }
210
            }
211
            $tokens[] = $token;
212
            $line = substr($line, $pos + 1);
213
        }
214
215
        // maybe the server forgot to send some closing braces
216
        while ($stack) {
217
            $child = $tokens;
218
            $tokens = array_pop($stack);
219
            $tokens[] = $child;
220
        }
221
222
        return $tokens;
223
    }
224
225
    /**
226
     * Read abd decode a response "line"
227
     * @param array|string $tokens to decode
228
     * @param string $wantedTag targeted tag
229
     * @param bool $dontParse if true only the unparsed line is returned in $tokens
230
     *
231
     * @return bool
232
     * @throws RuntimeException
233
     */
234
    public function readLine(&$tokens = [], $wantedTag = '*', $dontParse = false) {
235
        $line = $this->nextTaggedLine($tag); // get next tag
236
        if (!$dontParse) {
237
            $tokens = $this->decodeLine($line);
238
        } else {
239
            $tokens = $line;
240
        }
241
        if ($this->debug) echo "<< ".$line."\n";
242
243
        // if tag is wanted tag we might be at the end of a multiline response
244
        return $tag == $wantedTag;
245
    }
246
247
    /**
248
     * Read all lines of response until given tag is found
249
     * @param string $tag request tag
250
     * @param bool $dontParse if true every line is returned unparsed instead of the decoded tokens
251
     *
252
     * @return void|null|bool|array tokens if success, false if error, null if bad request
253
     * @throws RuntimeException
254
     */
255
    public function readResponse($tag, $dontParse = false) {
256
        $lines = [];
257
        $tokens = null; // define $tokens variable before first use
258
        do {
259
            $readAll = $this->readLine($tokens, $tag, $dontParse);
260
            $lines[] = $tokens;
261
        } while (!$readAll);
262
263
        if ($dontParse) {
264
            // First two chars are still needed for the response code
265
            $tokens = [substr($tokens, 0, 2)];
266
        }
267
268
        // last line has response code
269
        if ($tokens[0] == 'OK') {
270
            return $lines ? $lines : true;
0 ignored issues
show
introduced by
$lines is an empty array, thus is always false.
Loading history...
271
        } elseif ($tokens[0] == 'NO') {
272
            return false;
273
        }
274
275
        return;
276
    }
277
278
    /**
279
     * Send a new request
280
     * @param string $command
281
     * @param array $tokens additional parameters to command, use escapeString() to prepare
282
     * @param string $tag provide a tag otherwise an autogenerated is returned
283
     *
284
     * @throws RuntimeException
285
     */
286
    public function sendRequest($command, $tokens = [], &$tag = null) {
287
        if (!$tag) {
288
            $this->noun++;
289
            $tag = 'TAG' . $this->noun;
290
        }
291
292
        $line = $tag . ' ' . $command;
293
294
        foreach ($tokens as $token) {
295
            if (is_array($token)) {
296
                if (fwrite($this->stream, $line . ' ' . $token[0] . "\r\n") === false) {
0 ignored issues
show
Bug introduced by
It seems like $this->stream can also be of type boolean; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

296
                if (fwrite(/** @scrutinizer ignore-type */ $this->stream, $line . ' ' . $token[0] . "\r\n") === false) {
Loading history...
297
                    throw new RuntimeException('failed to write - connection closed?');
298
                }
299
                if (!$this->assumedNextLine('+ ')) {
300
                    throw new RuntimeException('failed to send literal string');
301
                }
302
                $line = $token[1];
303
            } else {
304
                $line .= ' ' . $token;
305
            }
306
        }
307
        if ($this->debug) echo ">> ".$line."\n";
308
309
        if (fwrite($this->stream, $line . "\r\n") === false) {
310
            throw new RuntimeException('failed to write - connection closed?');
311
        }
312
    }
313
314
    /**
315
     * Send a request and get response at once
316
     * @param string $command
317
     * @param array $tokens parameters as in sendRequest()
318
     * @param bool $dontParse if true unparsed lines are returned instead of tokens
319
     *
320
     * @return void|null|bool|array response as in readResponse()
321
     * @throws RuntimeException
322
     */
323
    public function requestAndResponse($command, $tokens = [], $dontParse = false) {
324
        $this->sendRequest($command, $tokens, $tag);
325
326
        return $this->readResponse($tag, $dontParse);
327
    }
328
329
    /**
330
     * Escape one or more literals i.e. for sendRequest
331
     * @param string|array $string the literal/-s
332
     *
333
     * @return string|array escape literals, literals with newline ar returned
334
     *                      as array('{size}', 'string');
335
     */
336
    public function escapeString($string) {
337
        if (func_num_args() < 2) {
338
            if (strpos($string, "\n") !== false) {
339
                return ['{' . strlen($string) . '}', $string];
340
            } else {
341
                return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $string) . '"';
342
            }
343
        }
344
        $result = [];
345
        foreach (func_get_args() as $string) {
0 ignored issues
show
introduced by
$string is overwriting one of the parameters of this function.
Loading history...
346
            $result[] = $this->escapeString($string);
347
        }
348
        return $result;
349
    }
350
351
    /**
352
     * Escape a list with literals or lists
353
     * @param array $list list with literals or lists as PHP array
354
     *
355
     * @return string escaped list for imap
356
     */
357
    public function escapeList($list) {
358
        $result = [];
359
        foreach ($list as $v) {
360
            if (!is_array($v)) {
361
                $result[] = $v;
362
                continue;
363
            }
364
            $result[] = $this->escapeList($v);
365
        }
366
        return '(' . implode(' ', $result) . ')';
367
    }
368
369
    /**
370
     * Login to a new session.
371
     * @param string $user username
372
     * @param string $password password
373
     *
374
     * @return bool|mixed
375
     * @throws AuthFailedException
376
     */
377
    public function login($user, $password) {
378
        try {
379
            return $this->requestAndResponse('LOGIN', $this->escapeString($user, $password), true);
0 ignored issues
show
Bug introduced by
It seems like $this->escapeString($user, $password) can also be of type string; however, parameter $tokens of Webklex\PHPIMAP\Connecti...l::requestAndResponse() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

379
            return $this->requestAndResponse('LOGIN', /** @scrutinizer ignore-type */ $this->escapeString($user, $password), true);
Loading history...
380
        } catch (RuntimeException $e) {
381
            throw new AuthFailedException("failed to authenticate", 0, $e);
382
        }
383
    }
384
385
    /**
386
     * Authenticate your current IMAP session.
387
     * @param string $user username
388
     * @param string $token access token
389
     *
390
     * @return bool
391
     * @throws AuthFailedException
392
     */
393
    public function authenticate($user, $token) {
394
        try {
395
            $authenticateParams = ['XOAUTH2', base64_encode("user=$user\1auth=Bearer $token\1\1")];
396
            $this->sendRequest('AUTHENTICATE', $authenticateParams);
397
398
            while (true) {
399
                $response = "";
400
                $is_plus = $this->readLine($response, '+', true);
401
                if ($is_plus) {
402
                    // try to log the challenge somewhere where it can be found
403
                    error_log("got an extra server challenge: $response");
404
                    // respond with an empty response.
405
                    $this->sendRequest('');
406
                } else {
407
                    if (preg_match('/^NO /i', $response) ||
408
                        preg_match('/^BAD /i', $response)) {
409
                        error_log("got failure response: $response");
410
                        return false;
411
                    } else if (preg_match("/^OK /i", $response)) {
412
                        return true;
413
                    }
414
                }
415
            }
416
        } catch (RuntimeException $e) {
417
            throw new AuthFailedException("failed to authenticate", 0, $e);
418
        }
419
        return false;
420
    }
421
422
    /**
423
     * Logout of imap server
424
     *
425
     * @return bool success
426
     */
427
    public function logout() {
428
        $result = false;
429
        if ($this->stream) {
430
            try {
431
                $result = $this->requestAndResponse('LOGOUT', [], true);
432
            } catch (Exception $e) {}
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
433
            fclose($this->stream);
0 ignored issues
show
Bug introduced by
It seems like $this->stream can also be of type true; however, parameter $stream of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

433
            fclose(/** @scrutinizer ignore-type */ $this->stream);
Loading history...
434
            $this->stream = null;
435
            $this->uid_cache = null;
436
        }
437
438
        return $result;
439
    }
440
441
    /**
442
     * Check if the current session is connected
443
     *
444
     * @return bool
445
     */
446
    public function connected(){
447
        return (boolean) $this->stream;
448
    }
449
450
    /**
451
     * Get an array of available capabilities
452
     *
453
     * @return array list of capabilities
454
     * @throws RuntimeException
455
     */
456
    public function getCapabilities() {
457
        $response = $this->requestAndResponse('CAPABILITY');
458
459
        if (!$response) return [];
0 ignored issues
show
Bug Best Practice introduced by
The expression $response of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
460
461
        $capabilities = [];
462
        foreach ($response as $line) {
0 ignored issues
show
Bug introduced by
The expression $response of type true is not traversable.
Loading history...
463
            $capabilities = array_merge($capabilities, $line);
464
        }
465
        return $capabilities;
466
    }
467
468
    /**
469
     * Examine and select have the same response.
470
     * @param string $command can be 'EXAMINE' or 'SELECT'
471
     * @param string $folder target folder
472
     *
473
     * @return bool|array
474
     * @throws RuntimeException
475
     */
476
    public function examineOrSelect($command = 'EXAMINE', $folder = 'INBOX') {
477
        $this->sendRequest($command, [$this->escapeString($folder)], $tag);
478
479
        $result = [];
480
        $tokens = null; // define $tokens variable before first use
481
        while (!$this->readLine($tokens, $tag)) {
482
            if ($tokens[0] == 'FLAGS') {
483
                array_shift($tokens);
484
                $result['flags'] = $tokens;
485
                continue;
486
            }
487
            switch ($tokens[1]) {
488
                case 'EXISTS':
489
                case 'RECENT':
490
                    $result[strtolower($tokens[1])] = $tokens[0];
491
                    break;
492
                case '[UIDVALIDITY':
493
                    $result['uidvalidity'] = (int)$tokens[2];
494
                    break;
495
                case '[UIDNEXT':
496
                    $result['uidnext'] = (int)$tokens[2];
497
                    break;
498
                default:
499
                    // ignore
500
                    break;
501
            }
502
        }
503
504
        if ($tokens[0] != 'OK') {
505
            return false;
506
        }
507
        return $result;
508
    }
509
510
    /**
511
     * Change the current folder
512
     * @param string $folder change to this folder
513
     *
514
     * @return bool|array see examineOrselect()
515
     * @throws RuntimeException
516
     */
517
    public function selectFolder($folder = 'INBOX') {
518
        $this->uid_cache = null;
519
520
        return $this->examineOrSelect('SELECT', $folder);
521
    }
522
523
    /**
524
     * Examine a given folder
525
     * @param string $folder examine this folder
526
     *
527
     * @return bool|array see examineOrselect()
528
     * @throws RuntimeException
529
     */
530
    public function examineFolder($folder = 'INBOX') {
531
        return $this->examineOrSelect('EXAMINE', $folder);
532
    }
533
534
    /**
535
     * Fetch one or more items of one or more messages
536
     * @param string|array $items items to fetch [RFC822.HEADER, FLAGS, RFC822.TEXT, etc]
537
     * @param int|array $from message for items or start message if $to !== null
538
     * @param int|null $to if null only one message ($from) is fetched, else it's the
539
     *                             last message, INF means last message available
540
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
541
     * message numbers instead.
542
     *
543
     * @return string|array if only one item of one message is fetched it's returned as string
544
     *                      if items of one message are fetched it's returned as (name => value)
545
     *                      if one items of messages are fetched it's returned as (msgno => value)
546
     *                      if items of messages are fetched it's returned as (msgno => (name => value))
547
     * @throws RuntimeException
548
     */
549
    public function fetch($items, $from, $to = null, $uid = IMAP::ST_UID) {
550
        if (is_array($from)) {
551
            $set = implode(',', $from);
552
        } elseif ($to === null) {
553
            $set = (int)$from;
554
        } elseif ($to === INF) {
0 ignored issues
show
introduced by
The condition $to === Webklex\PHPIMAP\Connection\Protocols\INF is always false.
Loading history...
555
            $set = (int)$from . ':*';
556
        } else {
557
            $set = (int)$from . ':' . (int)$to;
558
        }
559
560
        $items = (array)$items;
561
        $itemList = $this->escapeList($items);
562
563
        $this->sendRequest($this->buildUIDCommand("FETCH", $uid), [$set, $itemList], $tag);
564
        $result = [];
565
        $tokens = null; // define $tokens variable before first use
566
        while (!$this->readLine($tokens, $tag)) {
567
            // ignore other responses
568
            if ($tokens[1] != 'FETCH') {
569
                continue;
570
            }
571
572
            // find array key of UID value; try the last elements, or search for it
573
            if ($uid) {
574
                $count = count($tokens[2]);
575
                if ($tokens[2][$count - 2] == 'UID') {
576
                    $uidKey = $count - 1;
577
                } else if ($tokens[2][0] == 'UID') {
578
                    $uidKey = 1;
579
                } else {
580
                    $uidKey = array_search('UID', $tokens[2]) + 1;
581
                }
582
            }
583
584
            // ignore other messages
585
            if ($to === null && !is_array($from) && ($uid ? $tokens[2][$uidKey] != $from : $tokens[0] != $from)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $uidKey does not seem to be defined for all execution paths leading up to this point.
Loading history...
586
                continue;
587
            }
588
589
            // if we only want one item we return that one directly
590
            if (count($items) == 1) {
591
                if ($tokens[2][0] == $items[0]) {
592
                    $data = $tokens[2][1];
593
                } elseif ($uid && $tokens[2][2] == $items[0]) {
594
                    $data = $tokens[2][3];
595
                } else {
596
                    // maybe the server send an other field we didn't wanted
597
                    $count = count($tokens[2]);
598
                    // we start with 2, because 0 was already checked
599
                    for ($i = 2; $i < $count; $i += 2) {
600
                        if ($tokens[2][$i] != $items[0]) {
601
                            continue;
602
                        }
603
                        $data = $tokens[2][$i + 1];
604
                        break;
605
                    }
606
                }
607
            } else {
608
                $data = [];
609
                while (key($tokens[2]) !== null) {
610
                    $data[current($tokens[2])] = next($tokens[2]);
611
                    next($tokens[2]);
612
                }
613
            }
614
615
            // if we want only one message we can ignore everything else and just return
616
            if ($to === null && !is_array($from) && ($uid ? $tokens[2][$uidKey] == $from : $tokens[0] == $from)) {
617
                // we still need to read all lines
618
                while (!$this->readLine($tokens, $tag))
619
620
                    return $data;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $data does not seem to be defined for all execution paths leading up to this point.
Loading history...
621
            }
622
            if ($uid) {
623
                $result[$tokens[2][$uidKey]] = $data;
624
            }else{
625
                $result[$tokens[0]] = $data;
626
            }
627
        }
628
629
        if ($to === null && !is_array($from)) {
630
            throw new RuntimeException('the single id was not found in response');
631
        }
632
633
        return $result;
634
    }
635
636
    /**
637
     * Fetch message headers
638
     * @param array|int $uids
639
     * @param string $rfc
640
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
641
     * message numbers instead.
642
     *
643
     * @return array
644
     * @throws RuntimeException
645
     */
646
    public function content($uids, $rfc = "RFC822", $uid = IMAP::ST_UID) {
647
        return $this->fetch(["$rfc.TEXT"], $uids, null, $uid);
648
    }
649
650
    /**
651
     * Fetch message headers
652
     * @param array|int $uids
653
     * @param string $rfc
654
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
655
     * message numbers instead.
656
     *
657
     * @return array
658
     * @throws RuntimeException
659
     */
660
    public function headers($uids, $rfc = "RFC822", $uid = IMAP::ST_UID){
661
        return $this->fetch(["$rfc.HEADER"], $uids, null, $uid);
662
    }
663
664
    /**
665
     * Fetch message flags
666
     * @param array|int $uids
667
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
668
     * message numbers instead.
669
     *
670
     * @return array
671
     * @throws RuntimeException
672
     */
673
    public function flags($uids, $uid = IMAP::ST_UID){
674
        return $this->fetch(["FLAGS"], $uids, null, $uid);
675
    }
676
677
    /**
678
     * Get uid for a given id
679
     * @param int|null $id message number
680
     *
681
     * @return array|string message number for given message or all messages as array
682
     * @throws MessageNotFoundException
683
     */
684
    public function getUid($id = null) {
685
686
        if ($this->enable_uid_cache && $this->uid_cache) {
687
            $uids = $this->uid_cache;
688
        } else {
689
            try {
690
                $uids = $this->fetch('UID', 1, INF);
0 ignored issues
show
Bug introduced by
Webklex\PHPIMAP\Connection\Protocols\INF of type double is incompatible with the type integer|null expected by parameter $to of Webklex\PHPIMAP\Connecti...s\ImapProtocol::fetch(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

690
                $uids = $this->fetch('UID', 1, /** @scrutinizer ignore-type */ INF);
Loading history...
691
                $this->setUidCache($uids); // set cache for this folder
692
            } catch (RuntimeException $e) {}
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
693
        }
694
695
        if ($id == null) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $id of type integer|null against null; this is ambiguous if the integer can be zero. Consider using a strict comparison === instead.
Loading history...
696
            return $uids;
697
        }
698
699
        foreach ($uids as $k => $v) {
700
            if ($k == $id) {
701
                return $v;
702
            }
703
        }
704
705
        // clear uid cache and run method again
706
        if ($this->enable_uid_cache && $this->uid_cache) {
707
            $this->setUidCache(null);
708
            return $this->getUid($id);
709
        }
710
711
        throw new MessageNotFoundException('unique id not found');
712
    }
713
714
    /**
715
     * Get a message number for a uid
716
     * @param string $id uid
717
     *
718
     * @return int message number
719
     * @throws MessageNotFoundException
720
     */
721
    public function getMessageNumber($id) {
722
        $ids = $this->getUid();
723
        foreach ($ids as $k => $v) {
724
            if ($v == $id) {
725
                return $k;
726
            }
727
        }
728
729
        throw new MessageNotFoundException('message number not found');
730
    }
731
732
    /**
733
     * Get a list of available folders
734
     * @param string $reference mailbox reference for list
735
     * @param string $folder mailbox name match with wildcards
736
     *
737
     * @return array folders that matched $folder as array(name => array('delimiter' => .., 'flags' => ..))
738
     * @throws RuntimeException
739
     */
740
    public function folders($reference = '', $folder = '*') {
741
        $result = [];
742
        $list = $this->requestAndResponse('LIST', $this->escapeString($reference, $folder));
0 ignored issues
show
Bug introduced by
It seems like $this->escapeString($reference, $folder) can also be of type string; however, parameter $tokens of Webklex\PHPIMAP\Connecti...l::requestAndResponse() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

742
        $list = $this->requestAndResponse('LIST', /** @scrutinizer ignore-type */ $this->escapeString($reference, $folder));
Loading history...
743
        if (!$list || $list === true) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $list of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
introduced by
The condition $list === true is always true.
Loading history...
744
            return $result;
745
        }
746
747
        foreach ($list as $item) {
748
            if (count($item) != 4 || $item[0] != 'LIST') {
749
                continue;
750
            }
751
            $result[$item[3]] = ['delimiter' => $item[2], 'flags' => $item[1]];
752
        }
753
754
        return $result;
755
    }
756
757
    /**
758
     * Manage flags
759
     * @param array $flags flags to set, add or remove - see $mode
760
     * @param int $from message for items or start message if $to !== null
761
     * @param int|null $to if null only one message ($from) is fetched, else it's the
762
     *                             last message, INF means last message available
763
     * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given
764
     * @param bool $silent if false the return values are the new flags for the wanted messages
765
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
766
     * message numbers instead.
767
     * @param null|string $item command used to store a flag
768
     *
769
     * @return bool|array new flags if $silent is false, else true or false depending on success
770
     * @throws RuntimeException
771
     */
772
    public function store(array $flags, $from, $to = null, $mode = null, $silent = true, $uid = IMAP::ST_UID, $item = null) {
773
        $flags = $this->escapeList($flags);
774
        $set = $this->buildSet($from, $to);
0 ignored issues
show
Bug introduced by
It seems like $to can also be of type integer; however, parameter $to of Webklex\PHPIMAP\Connecti...mapProtocol::buildSet() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

774
        $set = $this->buildSet($from, /** @scrutinizer ignore-type */ $to);
Loading history...
775
776
        $command = $this->buildUIDCommand("STORE", $uid);
777
        $item = ($mode == '-' ? "-" : "+").($item === null ? "FLAGS" : $item).($silent ? '.SILENT' : "");
778
779
        $response = $this->requestAndResponse($command, [$set, $item, $flags], $silent);
780
781
        if ($silent) {
782
            return (bool)$response;
783
        }
784
785
        $result = [];
786
        foreach ($response as $token) {
0 ignored issues
show
Bug introduced by
The expression $response of type boolean|null is not traversable.
Loading history...
787
            if ($token[1] != 'FETCH' || $token[2][0] != 'FLAGS') {
788
                continue;
789
            }
790
            $result[$token[0]] = $token[2][1];
791
        }
792
793
        return $result;
794
    }
795
796
    /**
797
     * Append a new message to given folder
798
     * @param string $folder name of target folder
799
     * @param string $message full message content
800
     * @param array $flags flags for new message
801
     * @param string $date date for new message
802
     *
803
     * @return bool success
804
     * @throws RuntimeException
805
     */
806
    public function appendMessage($folder, $message, $flags = null, $date = null) {
807
        $tokens = [];
808
        $tokens[] = $this->escapeString($folder);
809
        if ($flags !== null) {
810
            $tokens[] = $this->escapeList($flags);
811
        }
812
        if ($date !== null) {
813
            $tokens[] = $this->escapeString($date);
814
        }
815
        $tokens[] = $this->escapeString($message);
816
817
        return $this->requestAndResponse('APPEND', $tokens, true);
818
    }
819
820
    /**
821
     * Copy a message set from current folder to an other folder
822
     * @param string $folder destination folder
823
     * @param $from
824
     * @param int|null $to if null only one message ($from) is fetched, else it's the
825
     *                         last message, INF means last message available
826
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
827
     * message numbers instead.
828
     *
829
     * @return bool success
830
     * @throws RuntimeException
831
     */
832
    public function copyMessage($folder, $from, $to = null, $uid = IMAP::ST_UID) {
833
        $set = $this->buildSet($from, $to);
0 ignored issues
show
Bug introduced by
It seems like $to can also be of type integer; however, parameter $to of Webklex\PHPIMAP\Connecti...mapProtocol::buildSet() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

833
        $set = $this->buildSet($from, /** @scrutinizer ignore-type */ $to);
Loading history...
834
        $command = $this->buildUIDCommand("COPY", $uid);
835
        return $this->requestAndResponse($command, [$set, $this->escapeString($folder)], true);
836
    }
837
838
    /**
839
     * Copy multiple messages to the target folder
840
     *
841
     * @param array<string> $messages List of message identifiers
842
     * @param string $folder Destination folder
843
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
844
     * message numbers instead.
845
     * @return array|bool Tokens if operation successful, false if an error occurred
846
     *
847
     * @throws RuntimeException
848
     */
849
    public function copyManyMessages($messages, $folder, $uid = IMAP::ST_UID) {
850
        $command = $this->buildUIDCommand("COPY", $uid);
851
852
        $set = implode(',', $messages);
853
        $tokens = [$set, $this->escapeString($folder)];
854
855
        return $this->requestAndResponse($command, $tokens, true);
856
    }
857
858
    /**
859
     * Move a message set from current folder to an other folder
860
     * @param string $folder destination folder
861
     * @param $from
862
     * @param int|null $to if null only one message ($from) is fetched, else it's the
863
     *                         last message, INF means last message available
864
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
865
     * message numbers instead.
866
     *
867
     * @return bool success
868
     * @throws RuntimeException
869
     */
870
    public function moveMessage($folder, $from, $to = null, $uid = IMAP::ST_UID) {
871
        $set = $this->buildSet($from, $to);
0 ignored issues
show
Bug introduced by
It seems like $to can also be of type integer; however, parameter $to of Webklex\PHPIMAP\Connecti...mapProtocol::buildSet() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

871
        $set = $this->buildSet($from, /** @scrutinizer ignore-type */ $to);
Loading history...
872
        $command = $this->buildUIDCommand("MOVE", $uid);
873
874
        return $this->requestAndResponse($command, [$set, $this->escapeString($folder)], true);
875
    }
876
877
    /**
878
     * Move multiple messages to the target folder
879
     * @param array<string> $messages List of message identifiers
880
     * @param string $folder Destination folder
881
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
882
     * message numbers instead.
883
     *
884
     * @return array|bool Tokens if operation successful, false if an error occurred
885
     * @throws RuntimeException
886
     */
887
    public function moveManyMessages($messages, $folder, $uid = IMAP::ST_UID) {
888
        $command = $this->buildUIDCommand("MOVE", $uid);
889
890
        $set = implode(',', $messages);
891
        $tokens = [$set, $this->escapeString($folder)];
892
893
        return $this->requestAndResponse($command, $tokens, true);
894
    }
895
896
    /**
897
     * Exchange identification information
898
     * Ref.: https://datatracker.ietf.org/doc/html/rfc2971
899
     *
900
     * @param null $ids
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $ids is correct as it would always require null to be passed?
Loading history...
901
     * @return array|bool|void|null
902
     *
903
     * @throws RuntimeException
904
     */
905
    public function ID($ids = null) {
906
        $token = "NIL";
907
        if (is_array($ids) && !empty($ids)) {
0 ignored issues
show
introduced by
The condition is_array($ids) is always false.
Loading history...
908
            $token = "(";
909
            foreach ($ids as $id) {
910
                $token .= '"'.$id.'" ';
911
            }
912
            $token = rtrim($token).")";
913
        }
914
915
        return $this->requestAndResponse("ID", [$token], true);
916
    }
917
918
    /**
919
     * Create a new folder (and parent folders if needed)
920
     * @param string $folder folder name
921
     *
922
     * @return bool success
923
     * @throws RuntimeException
924
     */
925
    public function createFolder($folder) {
926
        return $this->requestAndResponse('CREATE', [$this->escapeString($folder)], true);
927
    }
928
929
    /**
930
     * Rename an existing folder
931
     * @param string $old old name
932
     * @param string $new new name
933
     *
934
     * @return bool success
935
     * @throws RuntimeException
936
     */
937
    public function renameFolder($old, $new) {
938
        return $this->requestAndResponse('RENAME', $this->escapeString($old, $new), true);
0 ignored issues
show
Bug introduced by
It seems like $this->escapeString($old, $new) can also be of type string; however, parameter $tokens of Webklex\PHPIMAP\Connecti...l::requestAndResponse() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

938
        return $this->requestAndResponse('RENAME', /** @scrutinizer ignore-type */ $this->escapeString($old, $new), true);
Loading history...
939
    }
940
941
    /**
942
     * Delete a folder
943
     * @param string $folder folder name
944
     *
945
     * @return bool success
946
     * @throws RuntimeException
947
     */
948
    public function deleteFolder($folder) {
949
        return $this->requestAndResponse('DELETE', [$this->escapeString($folder)], true);
950
    }
951
952
    /**
953
     * Subscribe to a folder
954
     * @param string $folder folder name
955
     *
956
     * @return bool success
957
     * @throws RuntimeException
958
     */
959
    public function subscribeFolder($folder) {
960
        return $this->requestAndResponse('SUBSCRIBE', [$this->escapeString($folder)], true);
961
    }
962
963
    /**
964
     * Unsubscribe from a folder
965
     * @param string $folder folder name
966
     *
967
     * @return bool success
968
     * @throws RuntimeException
969
     */
970
    public function unsubscribeFolder($folder) {
971
        return $this->requestAndResponse('UNSUBSCRIBE', [$this->escapeString($folder)], true);
972
    }
973
974
    /**
975
     * Apply session saved changes to the server
976
     *
977
     * @return bool success
978
     * @throws RuntimeException
979
     */
980
    public function expunge() {
981
        return $this->requestAndResponse('EXPUNGE');
982
    }
983
984
    /**
985
     * Send noop command
986
     *
987
     * @return bool success
988
     * @throws RuntimeException
989
     */
990
    public function noop() {
991
        return $this->requestAndResponse('NOOP');
992
    }
993
994
    /**
995
     * Retrieve the quota level settings, and usage statics per mailbox
996
     * @param $username
997
     *
998
     * @return array
999
     * @throws RuntimeException
1000
     */
1001
    public function getQuota($username) {
1002
        return $this->requestAndResponse("GETQUOTA", ['"#user/'.$username.'"']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->requestAnd...r/' . $username . '"')) also could return the type boolean which is incompatible with the documented return type array.
Loading history...
1003
    }
1004
1005
    /**
1006
     * Retrieve the quota settings per user
1007
     * @param string $quota_root
1008
     *
1009
     * @return array
1010
     * @throws RuntimeException
1011
     */
1012
    public function getQuotaRoot($quota_root = 'INBOX') {
1013
        return $this->requestAndResponse("QUOTA", [$quota_root]);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->requestAnd...A', array($quota_root)) also could return the type boolean which is incompatible with the documented return type array.
Loading history...
1014
    }
1015
1016
    /**
1017
     * Send idle command
1018
     *
1019
     * @throws RuntimeException
1020
     */
1021
    public function idle() {
1022
        $this->sendRequest("IDLE");
1023
        if (!$this->assumedNextLine('+ ')) {
1024
            throw new RuntimeException('idle failed');
1025
        }
1026
    }
1027
1028
    /**
1029
     * Send done command
1030
     * @throws RuntimeException
1031
     */
1032
    public function done() {
1033
        if (fwrite($this->stream, "DONE\r\n") === false) {
0 ignored issues
show
Bug introduced by
It seems like $this->stream can also be of type boolean; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1033
        if (fwrite(/** @scrutinizer ignore-type */ $this->stream, "DONE\r\n") === false) {
Loading history...
1034
            throw new RuntimeException('failed to write - connection closed?');
1035
        }
1036
        return true;
1037
    }
1038
1039
    /**
1040
     * Search for matching messages
1041
     * @param array $params
1042
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
1043
     * message numbers instead.
1044
     *
1045
     * @return array message ids
1046
     * @throws RuntimeException
1047
     */
1048
    public function search(array $params, $uid = IMAP::ST_UID) {
1049
        $command = $this->buildUIDCommand("SEARCH", $uid);
1050
        $response = $this->requestAndResponse($command, $params);
1051
        if (!$response) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $response of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
1052
            return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response could also return false which is incompatible with the documented return type array. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
1053
        }
1054
1055
        foreach ($response as $ids) {
0 ignored issues
show
Bug introduced by
The expression $response of type true is not traversable.
Loading history...
1056
            if ($ids[0] == 'SEARCH') {
1057
                array_shift($ids);
1058
                return $ids;
1059
            }
1060
        }
1061
        return [];
1062
    }
1063
1064
    /**
1065
     * Get a message overview
1066
     * @param string $sequence
1067
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
1068
     * message numbers instead.
1069
     *
1070
     * @return array
1071
     * @throws RuntimeException
1072
     * @throws MessageNotFoundException
1073
     * @throws InvalidMessageDateException
1074
     */
1075
    public function overview($sequence, $uid = IMAP::ST_UID) {
1076
        $result = [];
1077
        list($from, $to) = explode(":", $sequence);
1078
1079
        $uids = $this->getUid();
1080
        $ids = [];
1081
        foreach ($uids as $msgn => $v) {
1082
            $id = $uid ? $v : $msgn;
1083
            if ( ($to >= $id && $from <= $id) || ($to === "*" && $from <= $id) ){
1084
                $ids[] = $id;
1085
            }
1086
        }
1087
        $headers = $this->headers($ids, "RFC822", $uid);
1088
        foreach ($headers as $id => $raw_header) {
1089
            $result[$id] = (new Header($raw_header, false))->getAttributes();
1090
        }
1091
        return $result;
1092
    }
1093
1094
    /**
1095
     * Enable the debug mode
1096
     */
1097
    public function enableDebug(){
1098
        $this->debug = true;
1099
    }
1100
1101
    /**
1102
     * Disable the debug mode
1103
     */
1104
    public function disableDebug(){
1105
        $this->debug = false;
1106
    }
1107
1108
    /**
1109
     * Build a valid UID number set
1110
     * @param $from
1111
     * @param null $to
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $to is correct as it would always require null to be passed?
Loading history...
1112
     *
1113
     * @return int|string
1114
     */
1115
    public function buildSet($from, $to = null) {
1116
        $set = (int)$from;
1117
        if ($to !== null) {
0 ignored issues
show
introduced by
The condition $to !== null is always false.
Loading history...
1118
            $set .= ':' . ($to == INF ? '*' : (int)$to);
1119
        }
1120
        return $set;
1121
    }
1122
}
1123