Passed
Pull Request — master (#331)
by
unknown
02:45
created

ImapProtocol::isUntaggedLine()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 2
rs 10
cc 1
nc 1
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(bool $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(string $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());
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(): string {
107
        $line = "";
108
        while (($next_char = fread($this->stream, 1)) !== false && $next_char !== "\n") {
109
            $line .= $next_char;
110
        }
111
        if ($line === "" && $next_char === false) {
112
            throw new RuntimeException('empty response');
113
        }
114
        if ($this->debug) echo "<< ".$line."\n";
115
        return $line . "\n";
116
    }
117
118
    /**
119
     * Get the next line and check if it starts with a given string
120
     * @param string $start
121
     *
122
     * @return bool
123
     * @throws RuntimeException
124
     */
125
    protected function assumedNextLine(string $start): bool {
126
        return strpos($this->nextLine(), $start) === 0;
127
    }
128
129
    /**
130
     * Get the next line and check if it starts with a given string
131
     * The server can send untagged status updates starting with '*' if we are not looking for a status update,
132
     * the untagged lines will be ignored.
133
     *
134
     * @param string $start
135
     *
136
     * @return bool
137
     * @throws RuntimeException
138
     */
139
    protected function assumedNextLineIgnoreUntagged(string $start): bool {
140
        do {
141
            $line = $this->nextLine();
142
        } while (!(strpos($start, '*') === 0) && $this->isUntaggedLine($line));
143
144
        return strpos($line, $start) === 0;
145
    }
146
147
    /**
148
     * Get the next line and split the tag
149
     * @param string|null $tag reference tag
150
     *
151
     * @return string next line
152
     * @throws RuntimeException
153
     */
154
    protected function nextTaggedLine(&$tag): string {
155
        $line = $this->nextLine();
156
        list($tag, $line) = explode(' ', $line, 2);
157
158
        return $line;
159
    }
160
161
    /**
162
     * Get the next line and split the tag
163
     * The server can send untagged status updates starting with '*', the untagged lines will be ignored.
164
     *
165
     * @param string|null $tag reference tag
166
     *
167
     * @return string next line
168
     * @throws RuntimeException
169
     */
170
    protected function nextTaggedLineIgnoreUntagged(&$tag): string {
171
        do {
172
            $line = $this->nextLine();
173
        } while ($this->isUntaggedLine($line));
174
175
        list($tag, $line) = explode(' ', $line, 2);
176
177
        return $line;
178
    }
179
180
    /**
181
     * Get the next line and check if it contains a given string and split the tag
182
     * @param string $start
183
     * @param $tag
184
     *
185
     * @return bool
186
     * @throws RuntimeException
187
     */
188
    protected function assumedNextTaggedLine(string $start, &$tag): bool {
189
        $line = $this->nextTaggedLine($tag);
190
        return strpos($line, $start) !== false;
191
    }
192
193
    /**
194
     * Get the next line and check if it contains a given string and split the tag
195
     * @param string $start
196
     * @param $tag
197
     *
198
     * @return bool
199
     * @throws RuntimeException
200
     */
201
    protected function assumedNextTaggedLineIgnoreUntagged(string $start, &$tag): bool {
202
        $line = $this->nextTaggedLineIgnoreUntagged($tag);
203
        return strpos($line, $start) !== false;
204
    }
205
206
    /**
207
     * RFC3501 - 2.2.2
208
     * Data transmitted by the server to the client and status responses
209
     * that do not indicate command completion are prefixed with the token
210
     * "*", and are called untagged responses.
211
     *
212
     * @param string $line
213
     * @return bool
214
     */
215
    protected function isUntaggedLine(string $line) : bool {
216
        return strpos($line, '* ') === 0;
217
    }
218
219
    /**
220
     * Split a given line in values. A value is literal of any form or a list
221
     * @param string $line
222
     *
223
     * @return array
224
     * @throws RuntimeException
225
     */
226
    protected function decodeLine(string $line): array {
227
        $tokens = [];
228
        $stack = [];
229
230
        //  replace any trailing <NL> including spaces with a single space
231
        $line = rtrim($line) . ' ';
232
        while (($pos = strpos($line, ' ')) !== false) {
233
            $token = substr($line, 0, $pos);
234
            if (!strlen($token)) {
235
                $line = substr($line, $pos + 1);
236
                continue;
237
            }
238
            while ($token[0] == '(') {
239
                $stack[] = $tokens;
240
                $tokens = [];
241
                $token = substr($token, 1);
242
            }
243
            if ($token[0] == '"') {
244
                if (preg_match('%^\(*"((.|\\\\|\\")*?)" *%', $line, $matches)) {
245
                    $tokens[] = $matches[1];
246
                    $line = substr($line, strlen($matches[0]));
247
                    continue;
248
                }
249
            }
250
            if ($token[0] == '{') {
251
                $endPos = strpos($token, '}');
252
                $chars = substr($token, 1, $endPos - 1);
253
                if (is_numeric($chars)) {
254
                    $token = '';
255
                    while (strlen($token) < $chars) {
256
                        $token .= $this->nextLine();
257
                    }
258
                    $line = '';
259
                    if (strlen($token) > $chars) {
260
                        $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

260
                        $line = substr($token, /** @scrutinizer ignore-type */ $chars);
Loading history...
261
                        $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

261
                        $token = substr($token, 0, /** @scrutinizer ignore-type */ $chars);
Loading history...
262
                    } else {
263
                        $line .= $this->nextLine();
264
                    }
265
                    $tokens[] = $token;
266
                    $line = trim($line) . ' ';
267
                    continue;
268
                }
269
            }
270
            if ($stack && $token[strlen($token) - 1] == ')') {
271
                // closing braces are not separated by spaces, so we need to count them
272
                $braces = strlen($token);
273
                $token = rtrim($token, ')');
274
                // only count braces if more than one
275
                $braces -= strlen($token) + 1;
276
                // only add if token had more than just closing braces
277
                if (rtrim($token) != '') {
278
                    $tokens[] = rtrim($token);
279
                }
280
                $token = $tokens;
281
                $tokens = array_pop($stack);
282
                // special handline if more than one closing brace
283
                while ($braces-- > 0) {
284
                    $tokens[] = $token;
285
                    $token = $tokens;
286
                    $tokens = array_pop($stack);
287
                }
288
            }
289
            $tokens[] = $token;
290
            $line = substr($line, $pos + 1);
291
        }
292
293
        // maybe the server forgot to send some closing braces
294
        while ($stack) {
295
            $child = $tokens;
296
            $tokens = array_pop($stack);
297
            $tokens[] = $child;
298
        }
299
300
        return $tokens;
301
    }
302
303
    /**
304
     * Read abd decode a response "line"
305
     * @param array|string $tokens to decode
306
     * @param string $wantedTag targeted tag
307
     * @param bool $dontParse if true only the unparsed line is returned in $tokens
308
     *
309
     * @return bool
310
     * @throws RuntimeException
311
     */
312
    public function readLine(&$tokens = [], string $wantedTag = '*', bool $dontParse = false): bool {
313
        $line = $this->nextTaggedLine($tag); // get next tag
314
        if (!$dontParse) {
315
            $tokens = $this->decodeLine($line);
316
        } else {
317
            $tokens = $line;
318
        }
319
320
        // if tag is wanted tag we might be at the end of a multiline response
321
        return $tag == $wantedTag;
322
    }
323
324
    /**
325
     * Read all lines of response until given tag is found
326
     * @param string $tag request tag
327
     * @param bool $dontParse if true every line is returned unparsed instead of the decoded tokens
328
     *
329
     * @return array|bool|null tokens if success, false if error, null if bad request
330
     * @throws RuntimeException
331
     */
332
    public function readResponse(string $tag, bool $dontParse = false) {
333
        $lines = [];
334
        $tokens = null; // define $tokens variable before first use
335
        do {
336
            $readAll = $this->readLine($tokens, $tag, $dontParse);
337
            $lines[] = $tokens;
338
        } while (!$readAll);
339
340
        if ($dontParse) {
341
            // First two chars are still needed for the response code
342
            $tokens = [substr($tokens, 0, 2)];
343
        }
344
345
        // last line has response code
346
        if ($tokens[0] == 'OK') {
347
            return $lines ? $lines : true;
0 ignored issues
show
introduced by
$lines is an empty array, thus is always false.
Loading history...
348
        } elseif ($tokens[0] == 'NO') {
349
            return false;
350
        }
351
352
        return null;
353
    }
354
355
    /**
356
     * Send a new request
357
     * @param string $command
358
     * @param array $tokens additional parameters to command, use escapeString() to prepare
359
     * @param string|null $tag provide a tag otherwise an autogenerated is returned
360
     *
361
     * @throws RuntimeException
362
     */
363
    public function sendRequest(string $command, array $tokens = [], string &$tag = null) {
364
        if (!$tag) {
365
            $this->noun++;
366
            $tag = 'TAG' . $this->noun;
367
        }
368
369
        $line = $tag . ' ' . $command;
370
371
        foreach ($tokens as $token) {
372
            if (is_array($token)) {
373
                $this->write($line . ' ' . $token[0]);
374
                if (!$this->assumedNextLine('+ ')) {
375
                    throw new RuntimeException('failed to send literal string');
376
                }
377
                $line = $token[1];
378
            } else {
379
                $line .= ' ' . $token;
380
            }
381
        }
382
        $this->write($line);
383
    }
384
385
    /**
386
     * Write data to the current stream
387
     * @param string $data
388
     * @return void
389
     * @throws RuntimeException
390
     */
391
    public function write(string $data) {
392
        if ($this->debug) echo ">> ".$data ."\n";
393
394
        if (fwrite($this->stream, $data . "\r\n") === false) {
395
            throw new RuntimeException('failed to write - connection closed?');
396
        }
397
    }
398
399
    /**
400
     * Send a request and get response at once
401
     * @param string $command
402
     * @param array $tokens parameters as in sendRequest()
403
     * @param bool $dontParse if true unparsed lines are returned instead of tokens
404
     *
405
     * @return array|bool|null response as in readResponse()
406
     * @throws RuntimeException
407
     */
408
    public function requestAndResponse(string $command, array $tokens = [], bool $dontParse = false) {
409
        $this->sendRequest($command, $tokens, $tag);
410
411
        return $this->readResponse($tag, $dontParse);
412
    }
413
414
    /**
415
     * Escape one or more literals i.e. for sendRequest
416
     * @param string|array $string the literal/-s
417
     *
418
     * @return string|array escape literals, literals with newline ar returned
419
     *                      as array('{size}', 'string');
420
     */
421
    public function escapeString($string) {
422
        if (func_num_args() < 2) {
423
            if (strpos($string, "\n") !== false) {
424
                return ['{' . strlen($string) . '}', $string];
425
            } else {
426
                return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $string) . '"';
427
            }
428
        }
429
        $result = [];
430
        foreach (func_get_args() as $string) {
0 ignored issues
show
introduced by
$string is overwriting one of the parameters of this function.
Loading history...
431
            $result[] = $this->escapeString($string);
432
        }
433
        return $result;
434
    }
435
436
    /**
437
     * Escape a list with literals or lists
438
     * @param array $list list with literals or lists as PHP array
439
     *
440
     * @return string escaped list for imap
441
     */
442
    public function escapeList(array $list): string {
443
        $result = [];
444
        foreach ($list as $v) {
445
            if (!is_array($v)) {
446
                $result[] = $v;
447
                continue;
448
            }
449
            $result[] = $this->escapeList($v);
450
        }
451
        return '(' . implode(' ', $result) . ')';
452
    }
453
454
    /**
455
     * Login to a new session.
456
     * @param string $user username
457
     * @param string $password password
458
     *
459
     * @return bool|mixed
460
     * @throws AuthFailedException
461
     */
462
    public function login(string $user, string $password): bool {
463
        try {
464
            $response = $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

464
            $response = $this->requestAndResponse('LOGIN', /** @scrutinizer ignore-type */ $this->escapeString($user, $password), true);
Loading history...
465
            return $response !== null && $response !== false;
466
        } catch (RuntimeException $e) {
467
            throw new AuthFailedException("failed to authenticate", 0, $e);
468
        }
469
    }
470
471
    /**
472
     * Authenticate your current IMAP session.
473
     * @param string $user username
474
     * @param string $token access token
475
     *
476
     * @return bool
477
     * @throws AuthFailedException
478
     */
479
    public function authenticate(string $user, string $token): bool {
480
        try {
481
            $authenticateParams = ['XOAUTH2', base64_encode("user=$user\1auth=Bearer $token\1\1")];
482
            $this->sendRequest('AUTHENTICATE', $authenticateParams);
483
484
            while (true) {
485
                $response = "";
486
                $is_plus = $this->readLine($response, '+', true);
487
                if ($is_plus) {
488
                    // try to log the challenge somewhere where it can be found
489
                    error_log("got an extra server challenge: $response");
490
                    // respond with an empty response.
491
                    $this->sendRequest('');
492
                } else {
493
                    if (preg_match('/^NO /i', $response) ||
494
                        preg_match('/^BAD /i', $response)) {
495
                        error_log("got failure response: $response");
496
                        return false;
497
                    } else if (preg_match("/^OK /i", $response)) {
498
                        return true;
499
                    }
500
                }
501
            }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return boolean. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
502
        } catch (RuntimeException $e) {
503
            throw new AuthFailedException("failed to authenticate", 0, $e);
504
        }
505
    }
506
507
    /**
508
     * Logout of imap server
509
     *
510
     * @return bool success
511
     */
512
    public function logout(): bool {
513
        $result = false;
514
        if ($this->stream) {
515
            try {
516
                $result = $this->requestAndResponse('LOGOUT', [], true);
517
            } catch (Exception $e) {}
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
518
            fclose($this->stream);
519
            $this->stream = null;
520
            $this->uid_cache = null;
521
        }
522
523
        return $result !== false;
524
    }
525
526
    /**
527
     * Check if the current session is connected
528
     *
529
     * @return bool
530
     */
531
    public function connected(): bool {
532
        return (boolean) $this->stream;
533
    }
534
535
    /**
536
     * Get an array of available capabilities
537
     *
538
     * @return array list of capabilities
539
     * @throws RuntimeException
540
     */
541
    public function getCapabilities(): array {
542
        $response = $this->requestAndResponse('CAPABILITY');
543
544
        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...
545
546
        $capabilities = [];
547
        foreach ($response as $line) {
0 ignored issues
show
Bug introduced by
The expression $response of type true is not traversable.
Loading history...
548
            $capabilities = array_merge($capabilities, $line);
549
        }
550
        return $capabilities;
551
    }
552
553
    /**
554
     * Examine and select have the same response.
555
     * @param string $command can be 'EXAMINE' or 'SELECT'
556
     * @param string $folder target folder
557
     *
558
     * @return bool|array
559
     * @throws RuntimeException
560
     */
561
    public function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX') {
562
        $this->sendRequest($command, [$this->escapeString($folder)], $tag);
563
564
        $result = [];
565
        $tokens = null; // define $tokens variable before first use
566
        while (!$this->readLine($tokens, $tag)) {
567
            if ($tokens[0] == 'FLAGS') {
568
                array_shift($tokens);
569
                $result['flags'] = $tokens;
570
                continue;
571
            }
572
            switch ($tokens[1]) {
573
                case 'EXISTS':
574
                case 'RECENT':
575
                    $result[strtolower($tokens[1])] = (int)$tokens[0];
576
                    break;
577
                case '[UIDVALIDITY':
578
                    $result['uidvalidity'] = (int)$tokens[2];
579
                    break;
580
                case '[UIDNEXT':
581
                    $result['uidnext'] = (int)$tokens[2];
582
                    break;
583
                case '[UNSEEN':
584
                    $result['unseen'] = (int)$tokens[2];
585
                    break;
586
                case '[NONEXISTENT]':
587
                    throw new RuntimeException("folder doesn't exist");
588
                default:
589
                    // ignore
590
                    break;
591
            }
592
        }
593
594
        if ($tokens[0] != 'OK') {
595
            return false;
596
        }
597
        return $result;
598
    }
599
600
    /**
601
     * Change the current folder
602
     * @param string $folder change to this folder
603
     *
604
     * @return bool|array see examineOrselect()
605
     * @throws RuntimeException
606
     */
607
    public function selectFolder(string $folder = 'INBOX') {
608
        $this->uid_cache = null;
609
610
        return $this->examineOrSelect('SELECT', $folder);
611
    }
612
613
    /**
614
     * Examine a given folder
615
     * @param string $folder examine this folder
616
     *
617
     * @return bool|array see examineOrselect()
618
     * @throws RuntimeException
619
     */
620
    public function examineFolder(string $folder = 'INBOX') {
621
        return $this->examineOrSelect('EXAMINE', $folder);
622
    }
623
624
    /**
625
     * Fetch one or more items of one or more messages
626
     * @param string|array $items items to fetch [RFC822.HEADER, FLAGS, RFC822.TEXT, etc]
627
     * @param int|array $from message for items or start message if $to !== null
628
     * @param int|null $to if null only one message ($from) is fetched, else it's the
629
     *                             last message, INF means last message available
630
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
631
     * message numbers instead.
632
     *
633
     * @return string|array if only one item of one message is fetched it's returned as string
634
     *                      if items of one message are fetched it's returned as (name => value)
635
     *                      if one item of messages are fetched it's returned as (msgno => value)
636
     *                      if items of messages are fetched it's returned as (msgno => (name => value))
637
     * @throws RuntimeException
638
     */
639
    public function fetch($items, $from, $to = null, $uid = IMAP::ST_UID) {
640
        if (is_array($from)) {
641
            $set = implode(',', $from);
642
        } elseif ($to === null) {
643
            $set = (int)$from;
644
        } elseif ($to === INF) {
0 ignored issues
show
introduced by
The condition $to === Webklex\PHPIMAP\Connection\Protocols\INF is always false.
Loading history...
645
            $set = (int)$from . ':*';
646
        } else {
647
            $set = (int)$from . ':' . (int)$to;
648
        }
649
650
        $items = (array)$items;
651
        $itemList = $this->escapeList($items);
652
653
        $this->sendRequest($this->buildUIDCommand("FETCH", $uid), [$set, $itemList], $tag);
654
        $result = [];
655
        $tokens = null; // define $tokens variable before first use
656
        while (!$this->readLine($tokens, $tag)) {
657
            // ignore other responses
658
            if ($tokens[1] != 'FETCH') {
659
                continue;
660
            }
661
662
            // find array key of UID value; try the last elements, or search for it
663
            if ($uid) {
664
                $count = count($tokens[2]);
665
                if ($tokens[2][$count - 2] == 'UID') {
666
                    $uidKey = $count - 1;
667
                } else if ($tokens[2][0] == 'UID') {
668
                    $uidKey = 1;
669
                } else {
670
                    $found = array_search('UID', $tokens[2]);
671
                    if ($found === false || $found === -1) {
672
                        continue;
673
                    }
674
675
                    $uidKey = $found + 1;
676
                }
677
            }
678
679
            // ignore other messages
680
            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...
681
                continue;
682
            }
683
684
            // if we only want one item we return that one directly
685
            if (count($items) == 1) {
686
                if ($tokens[2][0] == $items[0]) {
687
                    $data = $tokens[2][1];
688
                } elseif ($uid && $tokens[2][2] == $items[0]) {
689
                    $data = $tokens[2][3];
690
                } else {
691
                    $expectedResponse = 0;
692
                    // maybe the server send an other field we didn't wanted
693
                    $count = count($tokens[2]);
694
                    // we start with 2, because 0 was already checked
695
                    for ($i = 2; $i < $count; $i += 2) {
696
                        if ($tokens[2][$i] != $items[0]) {
697
                            continue;
698
                        }
699
                        $data = $tokens[2][$i + 1];
700
                        $expectedResponse = 1;
701
                        break;
702
                    }
703
                    if (!$expectedResponse) {
704
                        continue;
705
                    }
706
                }
707
            } else {
708
                $data = [];
709
                while (key($tokens[2]) !== null) {
710
                    $data[current($tokens[2])] = next($tokens[2]);
711
                    next($tokens[2]);
712
                }
713
            }
714
715
            // if we want only one message we can ignore everything else and just return
716
            if ($to === null && !is_array($from) && ($uid ? $tokens[2][$uidKey] == $from : $tokens[0] == $from)) {
717
                // we still need to read all lines
718
                while (!$this->readLine($tokens, $tag))
719
720
                    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...
721
            }
722
            if ($uid) {
723
                $result[$tokens[2][$uidKey]] = $data;
724
            }else{
725
                $result[$tokens[0]] = $data;
726
            }
727
        }
728
729
        if ($to === null && !is_array($from)) {
730
            throw new RuntimeException('the single id was not found in response');
731
        }
732
733
        return $result;
734
    }
735
736
    /**
737
     * Fetch message headers
738
     * @param array|int $uids
739
     * @param string $rfc
740
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
741
     * message numbers instead.
742
     *
743
     * @return array
744
     * @throws RuntimeException
745
     */
746
    public function content($uids, string $rfc = "RFC822", $uid = IMAP::ST_UID): array {
747
        $result = $this->fetch(["$rfc.TEXT"], $uids, null, $uid);
748
        return is_array($result) ? $result : [];
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
749
    }
750
751
    /**
752
     * Fetch message headers
753
     * @param array|int $uids
754
     * @param string $rfc
755
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
756
     * message numbers instead.
757
     *
758
     * @return array
759
     * @throws RuntimeException
760
     */
761
    public function headers($uids, string $rfc = "RFC822", $uid = IMAP::ST_UID): array{
762
        $result = $this->fetch(["$rfc.HEADER"], $uids, null, $uid);
763
        return $result === "" ? [] : $result;
0 ignored issues
show
introduced by
The condition $result === '' is always false.
Loading history...
764
    }
765
766
    /**
767
     * Fetch message flags
768
     * @param array|int $uids
769
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
770
     * message numbers instead.
771
     *
772
     * @return array
773
     * @throws RuntimeException
774
     */
775
    public function flags($uids, $uid = IMAP::ST_UID): array {
776
        $result = $this->fetch(["FLAGS"], $uids, null, $uid);
777
        return is_array($result) ? $result : [];
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
778
    }
779
780
    /**
781
     * Get uid for a given id
782
     * @param int|null $id message number
783
     *
784
     * @return array|string message number for given message or all messages as array
785
     * @throws MessageNotFoundException
786
     */
787
    public function getUid($id = null) {
788
        if (!$this->enable_uid_cache || $this->uid_cache === null || ($this->uid_cache && count($this->uid_cache) <= 0)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->uid_cache of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
789
            try {
790
                $this->setUidCache($this->fetch('UID', 1, INF)); // set cache for this folder
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

790
                $this->setUidCache($this->fetch('UID', 1, /** @scrutinizer ignore-type */ INF)); // set cache for this folder
Loading history...
791
            } catch (RuntimeException $e) {}
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
792
        }
793
        $uids = $this->uid_cache;
794
795
        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...
796
            return $uids;
797
        }
798
799
        foreach ($uids as $k => $v) {
800
            if ($k == $id) {
801
                return $v;
802
            }
803
        }
804
805
        // clear uid cache and run method again
806
        if ($this->enable_uid_cache && $this->uid_cache) {
807
            $this->setUidCache(null);
808
            return $this->getUid($id);
809
        }
810
811
        throw new MessageNotFoundException('unique id not found');
812
    }
813
814
    /**
815
     * Get a message number for a uid
816
     * @param string $id uid
817
     *
818
     * @return int message number
819
     * @throws MessageNotFoundException
820
     */
821
    public function getMessageNumber(string $id): int {
822
        $ids = $this->getUid();
823
        foreach ($ids as $k => $v) {
824
            if ($v == $id) {
825
                return (int)$k;
826
            }
827
        }
828
829
        throw new MessageNotFoundException('message number not found');
830
    }
831
832
    /**
833
     * Get a list of available folders
834
     * @param string $reference mailbox reference for list
835
     * @param string $folder mailbox name match with wildcards
836
     *
837
     * @return array folders that matched $folder as array(name => array('delimiter' => .., 'flags' => ..))
838
     * @throws RuntimeException
839
     */
840
    public function folders(string $reference = '', string $folder = '*'): array {
841
        $result = [];
842
        $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

842
        $list = $this->requestAndResponse('LIST', /** @scrutinizer ignore-type */ $this->escapeString($reference, $folder));
Loading history...
843
        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...
844
            return $result;
845
        }
846
847
        foreach ($list as $item) {
848
            if (count($item) != 4 || $item[0] != 'LIST') {
849
                continue;
850
            }
851
            $result[$item[3]] = ['delimiter' => $item[2], 'flags' => $item[1]];
852
        }
853
854
        return $result;
855
    }
856
857
    /**
858
     * Manage flags
859
     * @param array $flags flags to set, add or remove - see $mode
860
     * @param int $from message for items or start message if $to !== null
861
     * @param int|null $to if null only one message ($from) is fetched, else it's the
862
     *                             last message, INF means last message available
863
     * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given
864
     * @param bool $silent if false the return values are the new flags for the wanted messages
865
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
866
     * message numbers instead.
867
     * @param null|string $item command used to store a flag
868
     *
869
     * @return bool|array new flags if $silent is false, else true or false depending on success
870
     * @throws RuntimeException
871
     */
872
    public function store(array $flags, int $from, $to = null, $mode = null, bool $silent = true, $uid = IMAP::ST_UID, $item = null) {
873
        $flags = $this->escapeList($flags);
874
        $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

874
        $set = $this->buildSet($from, /** @scrutinizer ignore-type */ $to);
Loading history...
875
876
        $command = $this->buildUIDCommand("STORE", $uid);
877
        $item = ($mode == '-' ? "-" : "+").($item === null ? "FLAGS" : $item).($silent ? '.SILENT' : "");
878
879
        $response = $this->requestAndResponse($command, [$set, $item, $flags], $silent);
880
881
        if ($silent) {
882
            return (bool)$response;
883
        }
884
885
        $result = [];
886
        foreach ($response as $token) {
0 ignored issues
show
Bug introduced by
The expression $response of type boolean|null is not traversable.
Loading history...
887
            if ($token[1] != 'FETCH' || $token[2][0] != 'FLAGS') {
888
                continue;
889
            }
890
            $result[$token[0]] = $token[2][1];
891
        }
892
893
        return $result;
894
    }
895
896
    /**
897
     * Append a new message to given folder
898
     * @param string $folder name of target folder
899
     * @param string $message full message content
900
     * @param array|null $flags flags for new message
901
     * @param string $date date for new message
902
     *
903
     * @return bool success
904
     * @throws RuntimeException
905
     */
906
    public function appendMessage(string $folder, string $message, $flags = null, $date = null): bool {
907
        $tokens = [];
908
        $tokens[] = $this->escapeString($folder);
909
        if ($flags !== null) {
910
            $tokens[] = $this->escapeList($flags);
911
        }
912
        if ($date !== null) {
913
            $tokens[] = $this->escapeString($date);
914
        }
915
        $tokens[] = $this->escapeString($message);
916
917
        return (bool) $this->requestAndResponse('APPEND', $tokens, true);
918
    }
919
920
    /**
921
     * Copy a message set from current folder to an other folder
922
     * @param string $folder destination folder
923
     * @param $from
924
     * @param int|null $to if null only one message ($from) is fetched, else it's the
925
     *                         last message, INF means last message available
926
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
927
     * message numbers instead.
928
     *
929
     * @return bool success
930
     * @throws RuntimeException
931
     */
932
    public function copyMessage(string $folder, $from, $to = null, $uid = IMAP::ST_UID): bool {
933
        $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

933
        $set = $this->buildSet($from, /** @scrutinizer ignore-type */ $to);
Loading history...
934
        $command = $this->buildUIDCommand("COPY", $uid);
935
        return (bool)$this->requestAndResponse($command, [$set, $this->escapeString($folder)], true);
936
    }
937
938
    /**
939
     * Copy multiple messages to the target folder
940
     *
941
     * @param array $messages List of message identifiers
942
     * @param string $folder Destination folder
943
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
944
     * message numbers instead.
945
     * @return array|bool Tokens if operation successful, false if an error occurred
946
     *
947
     * @throws RuntimeException
948
     */
949
    public function copyManyMessages(array $messages, string $folder, $uid = IMAP::ST_UID) {
950
        $command = $this->buildUIDCommand("COPY", $uid);
951
952
        $set = implode(',', $messages);
953
        $tokens = [$set, $this->escapeString($folder)];
954
955
        return $this->requestAndResponse($command, $tokens, true);
956
    }
957
958
    /**
959
     * Move a message set from current folder to an other folder
960
     * @param string $folder destination folder
961
     * @param $from
962
     * @param int|null $to if null only one message ($from) is fetched, else it's the
963
     *                         last message, INF means last message available
964
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
965
     * message numbers instead.
966
     *
967
     * @return bool success
968
     * @throws RuntimeException
969
     */
970
    public function moveMessage(string $folder, $from, $to = null, $uid = IMAP::ST_UID): bool {
971
        $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

971
        $set = $this->buildSet($from, /** @scrutinizer ignore-type */ $to);
Loading history...
972
        $command = $this->buildUIDCommand("MOVE", $uid);
973
974
        return (bool)$this->requestAndResponse($command, [$set, $this->escapeString($folder)], true);
975
    }
976
977
    /**
978
     * Move multiple messages to the target folder
979
     * @param array $messages List of message identifiers
980
     * @param string $folder Destination folder
981
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
982
     * message numbers instead.
983
     *
984
     * @return array|bool Tokens if operation successful, false if an error occurred
985
     * @throws RuntimeException
986
     */
987
    public function moveManyMessages(array $messages, string $folder, $uid = IMAP::ST_UID) {
988
        $command = $this->buildUIDCommand("MOVE", $uid);
989
990
        $set = implode(',', $messages);
991
        $tokens = [$set, $this->escapeString($folder)];
992
993
        return $this->requestAndResponse($command, $tokens, true);
994
    }
995
996
    /**
997
     * Exchange identification information
998
     * Ref.: https://datatracker.ietf.org/doc/html/rfc2971
999
     *
1000
     * @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...
1001
     * @return array|bool|void|null
1002
     *
1003
     * @throws RuntimeException
1004
     */
1005
    public function ID($ids = null) {
1006
        $token = "NIL";
1007
        if (is_array($ids) && !empty($ids)) {
0 ignored issues
show
introduced by
The condition is_array($ids) is always false.
Loading history...
1008
            $token = "(";
1009
            foreach ($ids as $id) {
1010
                $token .= '"'.$id.'" ';
1011
            }
1012
            $token = rtrim($token).")";
1013
        }
1014
1015
        return $this->requestAndResponse("ID", [$token], true);
1016
    }
1017
1018
    /**
1019
     * Create a new folder (and parent folders if needed)
1020
     * @param string $folder folder name
1021
     *
1022
     * @return bool success
1023
     * @throws RuntimeException
1024
     */
1025
    public function createFolder(string $folder): bool {
1026
        return (bool)$this->requestAndResponse('CREATE', [$this->escapeString($folder)], true);
1027
    }
1028
1029
    /**
1030
     * Rename an existing folder
1031
     * @param string $old old name
1032
     * @param string $new new name
1033
     *
1034
     * @return bool success
1035
     * @throws RuntimeException
1036
     */
1037
    public function renameFolder(string $old, string $new): bool {
1038
        return (bool)$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

1038
        return (bool)$this->requestAndResponse('RENAME', /** @scrutinizer ignore-type */ $this->escapeString($old, $new), true);
Loading history...
1039
    }
1040
1041
    /**
1042
     * Delete a folder
1043
     * @param string $folder folder name
1044
     *
1045
     * @return bool success
1046
     * @throws RuntimeException
1047
     */
1048
    public function deleteFolder(string $folder): bool {
1049
        return (bool)$this->requestAndResponse('DELETE', [$this->escapeString($folder)], true);
1050
    }
1051
1052
    /**
1053
     * Subscribe to a folder
1054
     * @param string $folder folder name
1055
     *
1056
     * @return bool success
1057
     * @throws RuntimeException
1058
     */
1059
    public function subscribeFolder(string $folder): bool {
1060
        return (bool)$this->requestAndResponse('SUBSCRIBE', [$this->escapeString($folder)], true);
1061
    }
1062
1063
    /**
1064
     * Unsubscribe from a folder
1065
     * @param string $folder folder name
1066
     *
1067
     * @return bool success
1068
     * @throws RuntimeException
1069
     */
1070
    public function unsubscribeFolder(string $folder): bool {
1071
        return (bool)$this->requestAndResponse('UNSUBSCRIBE', [$this->escapeString($folder)], true);
1072
    }
1073
1074
    /**
1075
     * Apply session saved changes to the server
1076
     *
1077
     * @return bool success
1078
     * @throws RuntimeException
1079
     */
1080
    public function expunge(): bool {
1081
        return (bool)$this->requestAndResponse('EXPUNGE');
1082
    }
1083
1084
    /**
1085
     * Send noop command
1086
     *
1087
     * @return bool success
1088
     * @throws RuntimeException
1089
     */
1090
    public function noop(): bool {
1091
        return (bool)$this->requestAndResponse('NOOP');
1092
    }
1093
1094
    /**
1095
     * Retrieve the quota level settings, and usage statics per mailbox
1096
     * @param $username
1097
     *
1098
     * @return array
1099
     * @throws RuntimeException
1100
     */
1101
    public function getQuota($username): array {
1102
        $result = $this->requestAndResponse("GETQUOTA", ['"#user/'.$username.'"']);
1103
        return is_array($result) ? $result : [];
0 ignored issues
show
introduced by
The condition is_array($result) is always false.
Loading history...
1104
    }
1105
1106
    /**
1107
     * Retrieve the quota settings per user
1108
     * @param string $quota_root
1109
     *
1110
     * @return array
1111
     * @throws RuntimeException
1112
     */
1113
    public function getQuotaRoot(string $quota_root = 'INBOX'): array {
1114
        $result = $this->requestAndResponse("QUOTA", [$quota_root]);
1115
        return is_array($result) ? $result : [];
0 ignored issues
show
introduced by
The condition is_array($result) is always false.
Loading history...
1116
    }
1117
1118
    /**
1119
     * Send idle command
1120
     *
1121
     * @throws RuntimeException
1122
     */
1123
    public function idle() {
1124
        $this->sendRequest("IDLE");
1125
        if (!$this->assumedNextLineIgnoreUntagged('+ ')) {
1126
            throw new RuntimeException('idle failed');
1127
        }
1128
    }
1129
1130
    /**
1131
     * Send done command
1132
     * @throws RuntimeException
1133
     */
1134
    public function done(): bool {
1135
        $this->write("DONE");
1136
        if (!$this->assumedNextTaggedLineIgnoreUntagged('OK', $tags)) {
1137
            throw new RuntimeException('done failed');
1138
        }
1139
        return true;
1140
    }
1141
1142
    /**
1143
     * Search for matching messages
1144
     * @param array $params
1145
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
1146
     * message numbers instead.
1147
     *
1148
     * @return array message ids
1149
     * @throws RuntimeException
1150
     */
1151
    public function search(array $params, $uid = IMAP::ST_UID): array {
1152
        $command = $this->buildUIDCommand("SEARCH", $uid);
1153
        $response = $this->requestAndResponse($command, $params);
1154
        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...
1155
1156
        foreach ($response as $ids) {
0 ignored issues
show
Bug introduced by
The expression $response of type true is not traversable.
Loading history...
1157
            if ($ids[0] == 'SEARCH') {
1158
                array_shift($ids);
1159
                return $ids;
1160
            }
1161
        }
1162
        return [];
1163
    }
1164
1165
    /**
1166
     * Get a message overview
1167
     * @param string $sequence
1168
     * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use
1169
     * message numbers instead.
1170
     *
1171
     * @return array
1172
     * @throws RuntimeException
1173
     * @throws MessageNotFoundException
1174
     * @throws InvalidMessageDateException
1175
     */
1176
    public function overview(string $sequence, $uid = IMAP::ST_UID): array {
1177
        $result = [];
1178
        list($from, $to) = explode(":", $sequence);
1179
1180
        $uids = $this->getUid();
1181
        $ids = [];
1182
        foreach ($uids as $msgn => $v) {
1183
            $id = $uid ? $v : $msgn;
1184
            if ( ($to >= $id && $from <= $id) || ($to === "*" && $from <= $id) ){
1185
                $ids[] = $id;
1186
            }
1187
        }
1188
        $headers = $this->headers($ids, "RFC822", $uid);
1189
        foreach ($headers as $id => $raw_header) {
1190
            $result[$id] = (new Header($raw_header, false))->getAttributes();
1191
        }
1192
        return $result;
1193
    }
1194
1195
    /**
1196
     * Enable the debug mode
1197
     */
1198
    public function enableDebug(){
1199
        $this->debug = true;
1200
    }
1201
1202
    /**
1203
     * Disable the debug mode
1204
     */
1205
    public function disableDebug(){
1206
        $this->debug = false;
1207
    }
1208
1209
    /**
1210
     * Build a valid UID number set
1211
     * @param $from
1212
     * @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...
1213
     *
1214
     * @return int|string
1215
     */
1216
    public function buildSet($from, $to = null) {
1217
        $set = (int)$from;
1218
        if ($to !== null) {
0 ignored issues
show
introduced by
The condition $to !== null is always false.
Loading history...
1219
            $set .= ':' . ($to == INF ? '*' : (int)$to);
1220
        }
1221
        return $set;
1222
    }
1223
}
1224