diff --git a/src/Folder.php b/src/Folder.php index 45d2931d..2fbfaadf 100755 --- a/src/Folder.php +++ b/src/Folder.php @@ -442,7 +442,14 @@ public function idle(callable $callback, int $timeout = 300): void { $idle_client = $this->client->clone(); $idle_client->connect(); $idle_client->openFolder($this->path, true); - $idle_client->getConnection()->idle(); + + // Fetch the protocol connection once, before entering IDLE. Client::getConnection() runs + // checkConnection(), which verifies the session with a NOOP. Sending a NOOP while the session + // is in IDLE violates rfc2177 - servers such as Dovecot answer "BAD Expected DONE" and end the + // IDLE, checkConnection() then silently opens a new session without IDLE and no EXISTS ever + // arrives. Inside the loop only the protocol connection itself must be used. + $connection = $idle_client->getConnection(); + $connection->idle(); $last_action = Carbon::now()->addSeconds($timeout); @@ -451,9 +458,12 @@ public function idle(callable $callback, int $timeout = 300): void { while (true) { try { // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand - $line = $idle_client->getConnection()->nextLine(Response::empty()); + $line = $connection->nextLine(Response::empty()); } catch (Exceptions\RuntimeException $e) { - if(strpos($e->getMessage(), "empty response") >= 0 && $idle_client->getConnection()->connected()) { + // Do not use connected() here - it sends a NOOP, which would end the IDLE session. + // A stream that is not at EOF is still alive (e.g. a plain read timeout). + $meta = $connection->meta(); + if(strpos($e->getMessage(), "empty response") >= 0 && !($meta["eof"] ?? true)) { continue; } if(!str_contains($e->getMessage(), "connection closed")) { diff --git a/tests/IdleTest.php b/tests/IdleTest.php new file mode 100644 index 00000000..7cf0e829 --- /dev/null +++ b/tests/IdleTest.php @@ -0,0 +1,113 @@ +server)) { + proc_terminate($this->server); + proc_close($this->server); + } + if ($this->log_file !== "" && file_exists($this->log_file)) { + unlink($this->log_file); + } + } + + /** + * Start the fake IMAP server and return its port + * @param int $lifetime seconds until the server shuts down + * + * @return int + */ + protected function startServer(int $lifetime): int { + $this->log_file = tempnam(sys_get_temp_dir(), "php-imap-idle-"); + $command = [PHP_BINARY, __DIR__ . "/support/idle_server.php", $this->log_file, (string)$lifetime]; + $this->server = proc_open($command, [1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes); + self::assertIsResource($this->server, "fake IMAP server could not be started"); + $port = (int)trim((string)fgets($pipes[1])); + self::assertGreaterThan(0, $port, "fake IMAP server did not report its port"); + return $port; + } + + /** + * Commands received by the fake server + * + * @return array + */ + protected function receivedCommands(): array { + $commands = []; + foreach (file($this->log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $row) { + [$connection, $idling, $line] = explode("\t", $row, 3); + $commands[] = ["connection" => (int)$connection, "idling" => $idling === "1", "line" => $line]; + } + return $commands; + } + + public function testIdleDoesNotSendCommandsDuringIdle(): void { + $port = $this->startServer(5); + + $manager = new ClientManager([ + "accounts" => [ + "default" => [ + "host" => "127.0.0.1", + "port" => $port, + "encryption" => false, + "validate_cert" => false, + "username" => "user", + "password" => "secret", + "protocol" => "imap", + ], + ], + ]); + $client = $manager->account("default"); + $client->connect(); + $folder = new Folder($client, "INBOX", "/", []); + + try { + // Third argument (max_runtime) is ignored by versions without it - the loop then + // ends with an exception once the fake server shuts down. + $folder->idle(function () {}, 1, 3); + } catch (\Throwable) { + // the server went away - only the recorded commands matter + } + + $commands = $this->receivedCommands(); + self::assertNotEmpty($commands); + + $during_idle = array_values(array_filter($commands, fn($c) => $c["idling"] && strtoupper($c["line"]) !== "DONE")); + self::assertSame([], $during_idle, "commands sent while the session was in IDLE"); + + $first_idle = null; + foreach ($commands as $index => $command) { + if (!$command["idling"] && str_ends_with(strtoupper($command["line"]), " IDLE")) { + $first_idle = $index; + break; + } + } + self::assertNotNull($first_idle, "IDLE was never issued"); + + // A login after IDLE was issued means the idle session was silently replaced + $later_logins = array_filter(array_slice($commands, $first_idle), fn($c) => str_contains(strtoupper($c["line"]), " LOGIN ")); + self::assertSame([], array_values($later_logins), "the idle session was silently reconnected"); + } +} diff --git a/tests/support/idle_server.php b/tests/support/idle_server.php new file mode 100644 index 00000000..889f0d5a --- /dev/null +++ b/tests/support/idle_server.php @@ -0,0 +1,114 @@ +\t\t" to the log file given as first argument. +* +* Usage: php idle_server.php +* Prints the listening port on the first line of STDOUT. +*/ + +$log_file = $argv[1]; +$lifetime = (float)($argv[2] ?? 5); + +$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr); +if (!$server) { + fwrite(STDERR, "cannot listen: $errstr\n"); + exit(1); +} +$name = stream_socket_get_name($server, false); +echo substr($name, strrpos($name, ":") + 1) . "\n"; +flush(); + +$clients = []; +$state = []; +$next_id = 1; +$end = microtime(true) + $lifetime; + +$send = function ($stream, string $data): void { + @fwrite($stream, $data . "\r\n"); +}; + +while (microtime(true) < $end) { + $read = array_merge([$server], array_values($clients)); + $write = null; + $except = null; + if (@stream_select($read, $write, $except, 0, 100000) === false) { + break; + } + foreach ($read as $stream) { + if ($stream === $server) { + $client = @stream_socket_accept($server, 0); + if ($client) { + $id = $next_id++; + $clients[$id] = $client; + $state[$id] = ["idling" => false, "idle_tag" => null]; + $send($client, "* OK [CAPABILITY IMAP4rev1 IDLE] fake server ready"); + } + continue; + } + $id = array_search($stream, $clients, true); + $line = fgets($stream); + if ($line === false || $line === "") { + fclose($stream); + unset($clients[$id], $state[$id]); + continue; + } + $line = rtrim($line, "\r\n"); + file_put_contents($log_file, $id . "\t" . ($state[$id]["idling"] ? 1 : 0) . "\t" . $line . "\n", FILE_APPEND); + + if ($state[$id]["idling"]) { + $tag = $state[$id]["idle_tag"]; + $state[$id]["idling"] = false; + if (strtoupper($line) === "DONE") { + $send($stream, "$tag OK Idle completed."); + } else { + // Same reaction as Dovecot: the IDLE command ends with BAD + $send($stream, "$tag BAD Expected DONE."); + } + continue; + } + + $parts = explode(" ", $line, 3); + $tag = $parts[0]; + $command = strtoupper($parts[1] ?? ""); + switch ($command) { + case "CAPABILITY": + $send($stream, "* CAPABILITY IMAP4rev1 IDLE"); + $send($stream, "$tag OK Capability completed."); + break; + case "SELECT": + case "EXAMINE": + $send($stream, "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)"); + $send($stream, "* 0 EXISTS"); + $send($stream, "* 0 RECENT"); + $send($stream, "* OK [UIDVALIDITY 1] UIDs valid"); + $send($stream, "* OK [UIDNEXT 1] Predicted next UID"); + $send($stream, "$tag OK [READ-WRITE] Select completed."); + break; + case "IDLE": + $state[$id]["idling"] = true; + $state[$id]["idle_tag"] = $tag; + $send($stream, "+ idling"); + break; + case "LOGOUT": + $send($stream, "* BYE Logging out"); + $send($stream, "$tag OK Logout completed."); + fclose($stream); + unset($clients[$id], $state[$id]); + break; + default: + $send($stream, "$tag OK $command completed."); + } + } +} + +foreach ($clients as $client) { + fclose($client); +} +fclose($server);