diff --git a/composer.json b/composer.json index 37efc1c..e2d6b76 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "ext-libxml": "*", "ext-zip": "*", "ext-fileinfo": "*", - "nesbot/carbon": "^2.62.1", + "nesbot/carbon": "^2.62.1|^3.8.0", "symfony/http-foundation": ">=2.8.0", "illuminate/pagination": ">=5.0.0" }, diff --git a/src/Connection/Protocols/ImapProtocol.php b/src/Connection/Protocols/ImapProtocol.php index 4d54579..a5fbf9b 100644 --- a/src/Connection/Protocols/ImapProtocol.php +++ b/src/Connection/Protocols/ImapProtocol.php @@ -138,6 +138,24 @@ protected function assumedNextLine(Response $response, string $start): bool { return str_starts_with($this->nextLine($response), $start); } + /** + * Get the next line and check if it starts with a given string + * The server can send untagged status updates starting with '*' if we are not looking for a status update, + * the untagged lines will be ignored. + * + * @param string $start + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextLineIgnoreUntagged(Response $response, string $start): bool { + do { + $line = $this->nextLine($response); + } while (!(str_starts_with($start, '*')) && $this->isUntaggedLine($line)); + + return str_starts_with($line, $start); + } + /** * Get the next line and split the tag * @param string|null $tag reference tag @@ -154,6 +172,25 @@ protected function nextTaggedLine(Response $response, ?string &$tag): string { return $line ?? ''; } + /** + * Get the next line and split the tag + * The server can send untagged status updates starting with '*', the untagged lines will be ignored. + * + * @param string|null $tag reference tag + * + * @return string next line + * @throws RuntimeException + */ + protected function nextTaggedLineIgnoreUntagged(Response $response, &$tag): string { + do { + $line = $this->nextLine($response); + } while ($this->isUntaggedLine($line)); + + list($tag, $line) = explode(' ', $line, 2); + + return $line; + } + /** * Get the next line and check if it contains a given string and split the tag * @param Response $response @@ -167,6 +204,32 @@ protected function assumedNextTaggedLine(Response $response, string $start, &$ta return str_contains($this->nextTaggedLine($response, $tag), $start); } + /** + * Get the next line and check if it contains a given string and split the tag + * @param string $start + * @param $tag + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextTaggedLineIgnoreUntagged(Response $response, string $start, &$tag): bool { + $line = $this->nextTaggedLineIgnoreUntagged($response, $tag); + return strpos($line, $start) !== false; + } + + /** + * RFC3501 - 2.2.2 + * Data transmitted by the server to the client and status responses + * that do not indicate command completion are prefixed with the token + * "*", and are called untagged responses. + * + * @param string $line + * @return bool + */ + protected function isUntaggedLine(string $line) : bool { + return str_starts_with($line, '* '); + } + /** * Split a given line in values. A value is literal of any form or a list * @param Response $response @@ -625,10 +688,12 @@ public function examineFolder(string $folder = 'INBOX'): Response { * @throws RuntimeException */ public function fetch(array|string $items, array|int $from, mixed $to = null, int|string $uid = IMAP::ST_UID): Response { - if (is_array($from)) { + if (is_array($from) && count($from) > 1) { $set = implode(',', $from); + } elseif (is_array($from) && count($from) === 1) { + $set = $from[0] . ':' . $from[0]; } elseif ($to === null) { - $set = $from; + $set = $from . ':' . $from; } elseif ($to == INF) { $set = $from . ':*'; } else { @@ -1188,7 +1253,7 @@ public function getQuotaRoot(string $quota_root = 'INBOX'): Response { */ public function idle() { $response = $this->sendRequest("IDLE"); - if (!$this->assumedNextLine($response, '+ ')) { + if (!$this->assumedNextLineIgnoreUntagged($response, '+ ')) { throw new RuntimeException('idle failed'); } } @@ -1200,7 +1265,7 @@ public function idle() { public function done(): bool { $response = new Response($this->noun, $this->debug); $this->write($response, "DONE"); - if (!$this->assumedNextTaggedLine($response, 'OK', $tags)) { + if (!$this->assumedNextTaggedLineIgnoreUntagged($response, 'OK', $tags)) { throw new RuntimeException('done failed'); } return true; diff --git a/src/Folder.php b/src/Folder.php index c9ca395..eb0d251 100755 --- a/src/Folder.php +++ b/src/Folder.php @@ -451,8 +451,17 @@ public function idle(callable $callback, int $timeout = 300): void { $sequence = ClientManager::get('options.sequence', IMAP::ST_MSGN); while (true) { - // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand - $line = $idle_client->getConnection()->nextLine(Response::empty()); + try { + // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand + $line = $idle_client->getConnection()->nextLine(Response::empty()); + } catch (Exceptions\RuntimeException $e) { + if(strpos($e->getMessage(), "empty response") >= 0 && $idle_client->getConnection()->connected()) { + continue; + } + if(!str_contains($e->getMessage(), "connection closed")) { + throw $e; + } + } if (($pos = strpos($line, "EXISTS")) !== false) { $msgn = (int)substr($line, 2, $pos - 2); diff --git a/src/Message.php b/src/Message.php index 09a534f..07b454d 100755 --- a/src/Message.php +++ b/src/Message.php @@ -529,7 +529,7 @@ public function getHTMLBody(): string { */ private function parseHeader(): void { $sequence_id = $this->getSequenceId(); - $headers = $this->client->getConnection()->headers([$sequence_id], "RFC822", $this->sequence)->validatedData(); + $headers = $this->client->getConnection()->headers([$sequence_id], "RFC822", $this->sequence)->setCanBeEmpty(true)->validatedData(); if (!isset($headers[$sequence_id])) { throw new MessageHeaderFetchingException("no headers found", 0); } @@ -582,7 +582,7 @@ private function parseFlags(): void { $sequence_id = $this->getSequenceId(); try { - $flags = $this->client->getConnection()->flags([$sequence_id], $this->sequence)->validatedData(); + $flags = $this->client->getConnection()->flags([$sequence_id], $this->sequence)->setCanBeEmpty(true)->validatedData(); } catch (Exceptions\RuntimeException $e) { throw new MessageFlagException("flag could not be fetched", 0, $e); }