Skip to content

Commit 279ad47

Browse files
committed
Ignore possible untagged lines after IDLE and DONE commands #445 (thanks @gazben)
1 parent be97400 commit 279ad47

File tree

4 files changed

+103
-70
lines changed

4 files changed

+103
-70
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip
1111
- Fix bug when multipart message getHTMLBody() method returns null #455 (thanks @michalkortas)
1212
- Fix: Improve return type hints and return docblocks for query classes #470 (thanks @olliescase)
1313
- Fix - Query - Chunked - Resolved infinite loop when start chunk > 1 #477 (thanks @NeekTheNook)
14-
- Attachment with symbols in filename #436
14+
- Attachment with symbols in filename #436 (thanks @nuernbergerA)
15+
- Ignore possible untagged lines after IDLE and DONE commands #445 (thanks @gazben)
1516

1617
### Added
1718
- IMAP STATUS command support added `Folder::status()` #424 (thanks @InterLinked1)

src/Connection/Protocols/ImapProtocol.php

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,25 @@ protected function assumedNextLine(Response $response, string $start): bool {
160160
return str_starts_with($this->nextLine($response), $start);
161161
}
162162

163+
/**
164+
* Get the next line and check if it starts with a given string
165+
* The server can send untagged status updates starting with '*' if we are not looking for a status update,
166+
* the untagged lines will be ignored.
167+
*
168+
* @param Response $response
169+
* @param string $start
170+
*
171+
* @return bool
172+
* @throws RuntimeException
173+
*/
174+
protected function assumedNextLineIgnoreUntagged(Response $response, string $start): bool {
175+
do {
176+
$line = $this->nextLine($response);
177+
} while (!(str_starts_with($start, '*')) && $this->isUntaggedLine($line));
178+
179+
return str_starts_with($line, $start);
180+
}
181+
163182
/**
164183
* Get the next line and split the tag
165184
* @param string|null $tag reference tag
@@ -176,6 +195,25 @@ protected function nextTaggedLine(Response $response, ?string &$tag): string {
176195
return $line ?? '';
177196
}
178197

198+
/**
199+
* Get the next line and split the tag
200+
* The server can send untagged status updates starting with '*', the untagged lines will be ignored.
201+
*
202+
* @param string|null $tag reference tag
203+
*
204+
* @return string next line
205+
* @throws RuntimeException
206+
*/
207+
protected function nextTaggedLineIgnoreUntagged(Response $response, &$tag): string {
208+
do {
209+
$line = $this->nextLine($response);
210+
} while ($this->isUntaggedLine($line));
211+
212+
list($tag, $line) = explode(' ', $line, 2);
213+
214+
return $line;
215+
}
216+
179217
/**
180218
* Get the next line and check if it contains a given string and split the tag
181219
* @param Response $response
@@ -189,6 +227,32 @@ protected function assumedNextTaggedLine(Response $response, string $start, &$ta
189227
return str_contains($this->nextTaggedLine($response, $tag), $start);
190228
}
191229

230+
/**
231+
* Get the next line and check if it contains a given string and split the tag
232+
* @param string $start
233+
* @param $tag
234+
*
235+
* @return bool
236+
* @throws RuntimeException
237+
*/
238+
protected function assumedNextTaggedLineIgnoreUntagged(Response $response, string $start, &$tag): bool {
239+
$line = $this->nextTaggedLineIgnoreUntagged($response, $tag);
240+
return strpos($line, $start) !== false;
241+
}
242+
243+
/**
244+
* RFC3501 - 2.2.2
245+
* Data transmitted by the server to the client and status responses
246+
* that do not indicate command completion are prefixed with the token
247+
* "*", and are called untagged responses.
248+
*
249+
* @param string $line
250+
* @return bool
251+
*/
252+
protected function isUntaggedLine(string $line) : bool {
253+
return str_starts_with($line, '* ');
254+
}
255+
192256
/**
193257
* Split a given line in values. A value is literal of any form or a list
194258
* @param Response $response
@@ -703,10 +767,12 @@ public function folderStatus(string $folder = 'INBOX', $arguments = ['MESSAGES',
703767
* @throws RuntimeException
704768
*/
705769
public function fetch(array|string $items, array|int $from, mixed $to = null, int|string $uid = IMAP::ST_UID): Response {
706-
if (is_array($from)) {
770+
if (is_array($from) && count($from) > 1) {
707771
$set = implode(',', $from);
772+
} elseif (is_array($from) && count($from) === 1) {
773+
$set = $from[0] . ':' . $from[0];
708774
} elseif ($to === null) {
709-
$set = $from;
775+
$set = $from . ':' . $from;
710776
} elseif ($to == INF) {
711777
$set = $from . ':*';
712778
} else {
@@ -1266,7 +1332,7 @@ public function getQuotaRoot(string $quota_root = 'INBOX'): Response {
12661332
*/
12671333
public function idle(): void {
12681334
$response = $this->sendRequest("IDLE");
1269-
if (!$this->assumedNextLine($response, '+ ')) {
1335+
if (!$this->assumedNextLineIgnoreUntagged($response, '+ ')) {
12701336
throw new RuntimeException('idle failed');
12711337
}
12721338
}
@@ -1278,7 +1344,7 @@ public function idle(): void {
12781344
public function done(): bool {
12791345
$response = new Response($this->noun, $this->debug);
12801346
$this->write($response, "DONE");
1281-
if (!$this->assumedNextTaggedLine($response, 'OK', $tags)) {
1347+
if (!$this->assumedNextTaggedLineIgnoreUntagged($response, 'OK', $tags)) {
12821348
throw new RuntimeException('done failed');
12831349
}
12841350
return true;

src/Folder.php

Lines changed: 29 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -121,22 +121,6 @@ class Folder {
121121
/** @var array */
122122
public array $status;
123123

124-
/** @var array */
125-
public array $attributes = [];
126-
127-
128-
const SPECIAL_ATTRIBUTES = [
129-
'haschildren' => ['\haschildren'],
130-
'hasnochildren' => ['\hasnochildren'],
131-
'template' => ['\template', '\templates'],
132-
'inbox' => ['\inbox'],
133-
'sent' => ['\sent'],
134-
'drafts' => ['\draft', '\drafts'],
135-
'archive' => ['\archive', '\archives'],
136-
'trash' => ['\trash'],
137-
'junk' => ['\junk', '\spam'],
138-
];
139-
140124
/**
141125
* Folder constructor.
142126
* @param Client $client
@@ -251,8 +235,8 @@ public function getChildren(): FolderCollection {
251235
*/
252236
protected function decodeName($name): string|array|bool|null {
253237
$parts = [];
254-
foreach(explode($this->delimiter, $name) as $item) {
255-
$parts[] = EncodingAliases::convert($item, "UTF7-IMAP");
238+
foreach (explode($this->delimiter, $name) as $item) {
239+
$parts[] = EncodingAliases::convert($item, "UTF7-IMAP", "UTF-8");
256240
}
257241

258242
return implode($this->delimiter, $parts);
@@ -280,14 +264,6 @@ protected function parseAttributes($attributes): void {
280264
$this->marked = in_array('\Marked', $attributes);
281265
$this->referral = in_array('\Referral', $attributes);
282266
$this->has_children = in_array('\HasChildren', $attributes);
283-
284-
array_map(function($el) {
285-
foreach(self::SPECIAL_ATTRIBUTES as $key => $attribute) {
286-
if(in_array(strtolower($el), $attribute)){
287-
$this->attributes[] = $key;
288-
}
289-
}
290-
}, $attributes);
291267
}
292268

293269
/**
@@ -308,7 +284,7 @@ protected function parseAttributes($attributes): void {
308284
public function move(string $new_name, bool $expunge = true): array {
309285
$this->client->checkConnection();
310286
$status = $this->client->getConnection()->renameFolder($this->full_name, $new_name)->validatedData();
311-
if($expunge) $this->client->expunge();
287+
if ($expunge) $this->client->expunge();
312288

313289
$folder = $this->client->getFolder($new_name);
314290
$event = $this->getEvent("folder", "moved");
@@ -334,7 +310,7 @@ public function move(string $new_name, bool $expunge = true): array {
334310
public function overview(string $sequence = null): array {
335311
$this->client->openFolder($this->path);
336312
$sequence = $sequence === null ? "1:*" : $sequence;
337-
$uid = $this->client->getConfig()->get('options.sequence', IMAP::ST_MSGN);
313+
$uid = ClientManager::get('options.sequence', IMAP::ST_MSGN);
338314
$response = $this->client->getConnection()->overview($sequence, $uid);
339315
return $response->validatedData();
340316
}
@@ -360,7 +336,7 @@ public function appendMessage(string $message, array $options = null, Carbon|str
360336
* date string that conforms to the rfc2060 specifications for a date_time value or be a Carbon object.
361337
*/
362338

363-
if($internal_date instanceof Carbon){
339+
if ($internal_date instanceof Carbon) {
364340
$internal_date = $internal_date->format('d-M-Y H:i:s O');
365341
}
366342

@@ -401,11 +377,11 @@ public function rename(string $new_name, bool $expunge = true): array {
401377
*/
402378
public function delete(bool $expunge = true): array {
403379
$status = $this->client->getConnection()->deleteFolder($this->path)->validatedData();
404-
if($this->client->getActiveFolder() == $this->path){
405-
$this->client->setActiveFolder();
380+
if ($this->client->getActiveFolder() == $this->path){
381+
$this->client->setActiveFolder(null);
406382
}
407383

408-
if($expunge) $this->client->expunge();
384+
if ($expunge) $this->client->expunge();
409385

410386
$event = $this->getEvent("folder", "deleted");
411387
$event::dispatch($this);
@@ -461,7 +437,7 @@ public function unsubscribe(): array {
461437
public function idle(callable $callback, int $timeout = 300): void {
462438
$this->client->setTimeout($timeout);
463439

464-
if(!in_array("IDLE", $this->client->getConnection()->getCapabilities()->validatedData())){
440+
if (!in_array("IDLE", $this->client->getConnection()->getCapabilities()->validatedData())) {
465441
throw new Exceptions\NotSupportedCapabilityException("IMAP server does not support IDLE");
466442
}
467443

@@ -474,15 +450,24 @@ public function idle(callable $callback, int $timeout = 300): void {
474450

475451
$sequence = $this->client->getConfig()->get('options.sequence', IMAP::ST_MSGN);
476452

477-
while(true) {
478-
// This polymorphic call is fine - Protocol::idle() will throw an exception beforehand
479-
$line = $idle_client->getConnection()->nextLine(Response::empty());
453+
while (true) {
454+
try {
455+
// This polymorphic call is fine - Protocol::idle() will throw an exception beforehand
456+
$line = $idle_client->getConnection()->nextLine(Response::empty());
457+
} catch (Exceptions\RuntimeException $e) {
458+
if(strpos($e->getMessage(), "empty response") >= 0 && $idle_client->getConnection()->connected()) {
459+
continue;
460+
}
461+
if(!str_contains($e->getMessage(), "connection closed")) {
462+
throw $e;
463+
}
464+
}
480465

481-
if(($pos = strpos($line, "EXISTS")) !== false){
466+
if (($pos = strpos($line, "EXISTS")) !== false) {
482467
$msgn = (int)substr($line, 2, $pos - 2);
483468

484469
// Check if the stream is still alive or should be considered stale
485-
if(!$this->client->isConnected() || $last_action->isBefore(Carbon::now())){
470+
if (!$this->client->isConnected() || $last_action->isBefore(Carbon::now())) {
486471
// Reset the connection before interacting with it. Otherwise, the resource might be stale which
487472
// would result in a stuck interaction. If you know of a way of detecting a stale resource, please
488473
// feel free to improve this logic. I tried a lot but nothing seem to work reliably...
@@ -516,7 +501,7 @@ public function idle(callable $callback, int $timeout = 300): void {
516501
}
517502

518503
/**
519-
* Get folder status information from the EXAMINE command
504+
* Get folder status information
520505
*
521506
* @return array
522507
* @throws ConnectionFailedException
@@ -526,39 +511,20 @@ public function idle(callable $callback, int $timeout = 300): void {
526511
* @throws AuthFailedException
527512
* @throws ResponseException
528513
*/
529-
public function status(): array {
530-
return $this->client->getConnection()->folderStatus($this->path)->validatedData();
514+
public function getStatus(): array {
515+
return $this->examine();
531516
}
532517

533518
/**
534-
* Get folder status information from the EXAMINE command
535-
*
536-
* @return array
537-
* @throws AuthFailedException
538519
* @throws ConnectionFailedException
539520
* @throws ImapBadRequestException
540521
* @throws ImapServerErrorException
541-
* @throws ResponseException
542522
* @throws RuntimeException
543-
*
544-
* @deprecated Use Folder::status() instead
545-
*/
546-
public function getStatus(): array {
547-
return $this->status();
548-
}
549-
550-
/**
551-
* Load folder status information from the EXAMINE command
552-
* @return Folder
553523
* @throws AuthFailedException
554-
* @throws ConnectionFailedException
555-
* @throws ImapBadRequestException
556-
* @throws ImapServerErrorException
557524
* @throws ResponseException
558-
* @throws RuntimeException
559525
*/
560526
public function loadStatus(): Folder {
561-
$this->status = $this->examine();
527+
$this->status = $this->getStatus();
562528
return $this;
563529
}
564530

@@ -606,8 +572,8 @@ public function getClient(): Client {
606572
* @param $delimiter
607573
*/
608574
public function setDelimiter($delimiter): void {
609-
if(in_array($delimiter, [null, '', ' ', false]) === true){
610-
$delimiter = $this->client->getConfig()->get('options.delimiter', '/');
575+
if (in_array($delimiter, [null, '', ' ', false]) === true) {
576+
$delimiter = ClientManager::get('options.delimiter', '/');
611577
}
612578

613579
$this->delimiter = $delimiter;

src/Message.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ public function getHTMLBody(): string {
556556
*/
557557
private function parseHeader(): void {
558558
$sequence_id = $this->getSequenceId();
559-
$headers = $this->client->getConnection()->headers([$sequence_id], "RFC822", $this->sequence)->validatedData();
559+
$headers = $this->client->getConnection()->headers([$sequence_id], "RFC822", $this->sequence)->setCanBeEmpty(true)->validatedData();
560560
if (!isset($headers[$sequence_id])) {
561561
throw new MessageHeaderFetchingException("no headers found", 0);
562562
}
@@ -609,7 +609,7 @@ private function parseFlags(): void {
609609

610610
$sequence_id = $this->getSequenceId();
611611
try {
612-
$flags = $this->client->getConnection()->flags([$sequence_id], $this->sequence)->validatedData();
612+
$flags = $this->client->getConnection()->flags([$sequence_id], $this->sequence)->setCanBeEmpty(true)->validatedData();
613613
} catch (Exceptions\RuntimeException $e) {
614614
throw new MessageFlagException("flag could not be fetched", 0, $e);
615615
}

0 commit comments

Comments
 (0)