Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/Folder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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")) {
Expand Down
113 changes: 113 additions & 0 deletions tests/IdleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php
/*
* File: IdleTest.php
* Category: -
*
* Description:
* Folder::idle() must not send any command other than DONE while the session is in IDLE.
* rfc2177 compliant servers (e.g. Dovecot) answer "BAD Expected DONE" and terminate the
* IDLE - new messages would then never be reported. Client::getConnection() verifies the
* session with a NOOP, so it must not be called inside the idle loop.
*/

namespace Tests;

use PHPUnit\Framework\TestCase;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Folder;

class IdleTest extends TestCase {

/** @var resource|null */
protected $server = null;

protected string $log_file = "";

protected function tearDown(): void {
if (is_resource($this->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<int, array{connection: int, idling: bool, line: string}>
*/
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");
}
}
114 changes: 114 additions & 0 deletions tests/support/idle_server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php
/*
* File: idle_server.php
* Category: Test support
*
* Description:
* Minimal fake IMAP server used by IdleTest. It mimics how rfc2177 compliant servers
* (e.g. Dovecot) react to a command other than DONE during IDLE: the IDLE command is
* terminated with "BAD Expected DONE". Every received command is logged as
* "<connection>\t<idling 0|1>\t<line>" to the log file given as first argument.
*
* Usage: php idle_server.php <log_file> <lifetime_seconds>
* 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);