<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library\mail;
use seekquarry\yioop\configs as C;
/**
* Minimal RFC 3501 IMAP client used by the Mail activity when a Yioop user
* has registered an external IMAP account (Gmail, Fastmail, a corporate
* mail server, and so on). The client opens a socket, runs tagged commands
* (LOGIN, SELECT, FETCH, STORE, EXPUNGE, LOGOUT), and returns a parsed
* status / detail / untagged-lines / literal-payloads tuple per command.
*
* The class deliberately stays small: it does not parse FETCH responses
* into MIME trees, decode header bodies, or implement IDLE. Callers are
* expected to invoke `send()` with a complete IMAP command string and
* interpret the structured return themselves. The MailController handlers
* built on top do exactly that.
*
* Three connect styles are supported: plain TCP (port 143, no encryption,
* for trusted local networks only), STARTTLS (negotiated on a plain socket
* after the greeting), and IMAPS (TLS wrapped from byte zero, port 993).
* The latter two are the appropriate choice for any account on the public
* internet.
*
* @author Chris Pollett
*/
class ImapClient
{
/**
* Connect-error token: the server's TLS certificate failed
* verification for a reason that is not one of the more
* specific cert tokens below (self-signed, an untrusted or
* unknown issuer, an incomplete chain, and so on). The thrown
* message carries ": <raw reason>" after it.
* @var string
*/
const ERROR_CERT_VERIFY = 'cert_verify';
/**
* Connect-error token: the server's TLS certificate is itself
* valid and trusted but was issued for a different host name
* than the one the account is configured with. This is a
* distinct failure from an untrusted certificate: the fix is
* usually to correct the Host field, not to disable
* verification. The thrown message carries ": <raw reason>".
* @var string
*/
const ERROR_CERT_HOSTNAME = 'cert_hostname';
/**
* Connect-error token: the server's TLS certificate has
* expired (or is not yet valid). The thrown message carries
* ": <raw reason>" after it.
* @var string
*/
const ERROR_CERT_EXPIRED = 'cert_expired';
/**
* Connect-error token: the TLS handshake itself failed (the
* port likely expects an unencrypted connection).
* @var string
*/
const ERROR_SECURE_CONNECT = 'secure_connect';
/**
* Connect-error token: the server actively refused the TCP
* connection.
* @var string
*/
const ERROR_CONNECTION_REFUSED = 'connection_refused';
/**
* Connect-error token: the connection attempt timed out with
* no response.
* @var string
*/
const ERROR_TIMED_OUT = 'timed_out';
/**
* Connect-error token: the host name could not be resolved.
* @var string
*/
const ERROR_HOST_NOT_FOUND = 'host_not_found';
/**
* Connect-error token: a connection failure that did not match
* any more specific category. When this is the token the
* thrown message may carry ": <raw detail>" after it.
* @var string
*/
const ERROR_CONNECT_FAILED = 'connect_failed';
/**
* Connect-error token: the server answered but its greeting was
* not the expected "* OK" line. The thrown message carries
* ": <greeting>" after it.
* @var string
*/
const ERROR_BAD_GREETING = 'bad_greeting';
/**
* Crypto method passed to stream_socket_enable_crypto for the
* STARTTLS upgrade: TLS 1.2 and TLS 1.3 only. The obsolete TLS
* 1.0 / 1.1 versions (which the bare STREAM_CRYPTO_METHOD_
* TLS_CLIENT constant would also allow on some PHP builds) are
* deliberately excluded.
* @var int
*/
const TLS_CRYPTO_METHOD = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT |
STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
/**
* How many bytes a single literal read may pull from the socket
* between cooperative yields. waitReadable() only hands the event
* loop back when the socket would block; a mail server streaming a
* large message body keeps the socket continuously readable, so
* without this the whole body is read in one tight loop that
* monopolises the single-process server for the length of the
* transfer. After this many bytes readBytes() suspends the fiber
* for one loop pass so every other connection makes progress, then
* resumes at once. Chosen large enough that ordinary small messages
* read straight through with no yield at all.
* @var int
*/
const COOPERATIVE_READ_YIELD = 65536;
/**
* Server connection
* @var resource
*/
protected $socket;
/**
* Monotonic counter used to mint unique IMAP command tags ("X1", "X2",
* ...). RFC 3501 requires that each tagged command in a session use a
* tag distinct from every other in-flight tag; mint-and-discard from a
* counter satisfies this trivially.
* @var int
*/
protected $tag_counter = 0;
/**
* Bytes read from the socket that have not yet been consumed by
* readLine() or readBytes(). Required because line-mode and
* byte-mode reads share a single underlying stream and need a small
* lookahead window for literal payloads.
* @var string
*/
protected $buffer = '';
/**
* Whether the underlying socket is still usable for IMAP
* commands. Set true at the end of the constructor (after the
* server greeting validates); set false by readLine/readBytes
* on EOF, by sendInner on a failed write, and by close(). The
* send() retry path consults isAlive() after each sendInner
* to decide whether to invoke the reconnect handler.
* @var bool
*/
protected $alive = false;
/**
* Optional callback invoked by send() after a connection-loss
* EOF is detected on a command. Signature:
* function (ImapClient $dead): bool
* The handler is expected to open a fresh ImapClient (full
* TCP / TLS / LOGIN dance), then call $dead->adoptSocketFrom(
* $fresh) so the original ImapClient reference held by the
* caller becomes usable again, then return true. Returning
* false (or absence of a handler) means send() reports the
* EOF up to its caller without retry. Registered by
* ImapMailBackend::client() right after a successful LOGIN
* so reconnect uses the same credentials.
* @var callable|null
*/
protected $reconnect_handler = null;
/**
* Establishes the IMAP session. When $injected_socket is null (the
* default) the constructor opens a plain TCP connection to
* $host:$port; otherwise it adopts the provided pre-opened stream
* (used by connectImaps() to hand in a TLS socket, and by unit
* tests to substitute a scripted pipe). In either case the
* constructor reads the server greeting and verifies it begins
* with "* OK" (RFC 3501 section 7.1.1).
*
* For TLS-protected accounts on the public internet, prefer the
* static factories connectStartTls() or connectImaps() rather
* than instantiating this class directly.
*
* @param string $host hostname or IP of the IMAP server (used for
* diagnostic messages when an injected socket is supplied)
* @param int $port TCP port (143 for cleartext, 993 for IMAPS)
* @param int $timeout socket-read timeout in seconds
* @param resource $injected_socket pre-opened stream to adopt
* instead of opening a new TCP connection; null to open
* cleartext on $host:$port
* @param resource $stream_context stream context to attach when
* opening the TCP connection; connectStartTls() passes one
* carrying the ssl peer_name so the later
* stream_socket_enable_crypto() call can verify the
* server certificate. Ignored when $injected_socket is set.
*/
public function __construct(
protected string $host,
protected int $port,
protected int $timeout = 5,
$injected_socket = null,
$stream_context = null)
{
if ($injected_socket === null) {
$errno = 0;
$errstr = '';
$context = $stream_context ?? stream_context_create();
$captured = self::captureStreamError(
function () use ($host, $port, $timeout, $context,
&$injected_socket, &$errno, &$errstr) {
$injected_socket = stream_socket_client(
"tcp://$host:$port", $errno, $errstr, $timeout,
STREAM_CLIENT_CONNECT, $context);
});
if (!$injected_socket) {
throw new \Exception(self::classifyConnectError(
$captured ?: $errstr));
}
stream_set_timeout($injected_socket, $timeout);
}
$this->socket = $injected_socket;
$greeting = $this->readLine();
if ($greeting === '') {
/* readLine returns '' only on EOF (a real greeting
always starts with "* OK", per RFC 3501 section
7.1.1, so the empty string is unambiguous). The
socket died before we got a banner; no point
trying to LOGIN. */
throw new \Exception(self::ERROR_BAD_GREETING .
": connection closed before greeting");
}
if (substr($greeting, 0, 4) !== '* OK') {
throw new \Exception(
self::ERROR_BAD_GREETING . ": $greeting");
}
$this->alive = true;
}
/**
* Closes the underlying socket cleanly on shutdown. Idempotent;
* safe to call multiple times.
*/
public function __destruct()
{
if (is_resource($this->socket)) {
fclose($this->socket);
$this->socket = null;
}
}
/**
* Convenience factory: connect on the plain port, then immediately
* issue STARTTLS and upgrade the socket to TLS. Returns the client
* ready for LOGIN. Throws on any TLS-handshake failure.
*
* The plain TCP socket is opened with a stream context that
* carries the ssl peer_name, so that when
* stream_socket_enable_crypto() upgrades the connection it can
* verify the server certificate against $host. Without this the
* crypto layer has no host name to check the certificate
* against and a perfectly valid certificate is rejected.
*
* @param string $host hostname of the IMAP server
* @param int $port plain port (typically 143)
* @param int $timeout socket-read timeout in seconds
* @param bool $allow_self_signed when true, certificate
* verification is turned off for this connection (the
* per-account "allow self-signed certificate" option)
* @return self the connected client with an encrypted socket
*/
public static function connectStartTls(string $host, int $port = 143,
int $timeout = 5, bool $allow_self_signed = false): self
{
$context = self::tlsContext($host, $allow_self_signed);
$client = new self($host, $port, $timeout, null, $context);
$response = $client->send("STARTTLS");
if ($response['status'] !== 'OK') {
throw new \Exception(
"STARTTLS refused by $host: {$response['detail']}");
}
$ok = false;
$captured = self::captureStreamError(
function () use ($client, &$ok) {
$ok = self::enableCryptoBounded($client->socket,
$client->timeout);
});
if ($ok !== true) {
throw new \Exception(self::classifyConnectError(
$captured ?: self::ERROR_SECURE_CONNECT));
}
return $client;
}
/**
* Convenience factory: connect on the IMAPS port using TLS from
* byte zero. Returns the client ready for LOGIN. Throws on any
* connect or handshake failure.
*
* @param string $host hostname of the IMAP server
* @param int $port TLS-wrapped port (typically 993)
* @param int $timeout socket-read timeout in seconds
* @param bool $allow_self_signed when true, certificate
* verification is turned off for this connection (the
* per-account "allow self-signed certificate" option)
* @return self the connected client with an encrypted socket
*/
public static function connectImaps(string $host, int $port = 993,
int $timeout = 5, bool $allow_self_signed = false): self
{
$errno = 0;
$errstr = '';
$socket = null;
$context = self::tlsContext($host, $allow_self_signed);
$captured = self::captureStreamError(
function () use ($host, $port, $timeout, $context,
&$socket, &$errno, &$errstr) {
$socket = stream_socket_client(
"tls://$host:$port", $errno, $errstr, $timeout,
STREAM_CLIENT_CONNECT, $context);
});
if (!$socket) {
throw new \Exception(self::classifyConnectError(
$captured ?: $errstr));
}
stream_set_timeout($socket, $timeout);
return new self($host, $port, $timeout, $socket);
}
/**
* Builds the stream context used for the TLS-protected connect
* paths. The context carries the ssl peer_name (so the
* certificate can be verified against the host the user
* configured) and turns SNI on. When $allow_self_signed is true
* the verification flags are flipped off and self-signed
* certificates are accepted: this backs the per-account "allow
* self-signed certificate" option and is a deliberate
* downgrade of transport security the user opted into.
*
* @param string $host hostname the certificate should match
* @param bool $allow_self_signed when true, disable certificate
* verification for this connection
* @return resource a stream context suitable for
* stream_socket_client or stream_socket_enable_crypto
*/
protected static function tlsContext(string $host,
bool $allow_self_signed): mixed
{
$verify = !$allow_self_signed;
return stream_context_create([
'ssl' => [
'peer_name' => $host,
'SNI_enabled' => true,
'verify_peer' => $verify,
'verify_peer_name' => $verify,
'allow_self_signed' => $allow_self_signed,
],
]);
}
/**
* Runs $callback with a temporary error handler installed that
* captures the text of any PHP warning the callback triggers
* (for example the "SSL operation failed" / "Failed to enable
* crypto" warnings stream_socket_client and
* stream_socket_enable_crypto emit on failure) instead of
* letting it reach Yioop's global handler, which would echo it
* raw into the page. The captured text is returned so the
* caller can fold it into a thrown exception; the previous
* error handler is always restored.
*
* @param callable $callback the stream operation to run
* @return string the captured warning text, or the empty string
* if the callback triggered no warning
*/
protected static function captureStreamError(callable $callback): string
{
$captured = '';
set_error_handler(
function ($errno, $errstr) use (&$captured) {
/*
a single failed handshake can raise more than one
warning (for example a "Peer certificate ... did
not match expected" notice followed by a generic
"Failed to enable crypto"); keep them all so
classifyConnectError can see the most specific
one rather than just whichever fired last
*/
$captured = ($captured === '') ? $errstr :
$captured . " | " . $errstr;
return true;
});
try {
$callback();
} finally {
restore_error_handler();
}
return $captured;
}
/**
* Completes a TLS handshake on an already-connected socket without
* ever blocking the process indefinitely.
*
* The ordinary blocking form of stream_socket_enable_crypto ignores
* the socket's read timeout, so a peer that accepts STARTTLS and then
* stalls part way through the handshake would freeze the caller
* forever. Because the web interface upgrades IMAP connections from
* inside the single-process web server, one such stalled peer would
* hang the whole site. This runs the handshake in non-blocking mode
* instead: it keeps asking the crypto layer to make progress and
* waits, only up to the deadline, for the socket to become ready
* between tries, giving up once the time is spent. Blocking mode is
* restored before returning so later reads behave as before.
*
* @param resource $socket the connected plaintext socket to upgrade
* @param int $timeout the longest time, in seconds, to spend on the
* handshake before giving up
* @return bool true if the handshake completed, false if it failed or
* ran past the timeout
*/
protected static function enableCryptoBounded($socket,
int $timeout): bool
{
$deadline = microtime(true) + max(1, $timeout);
stream_set_blocking($socket, false);
$result = stream_socket_enable_crypto($socket, true,
self::TLS_CRYPTO_METHOD);
while ($result === 0) {
$remaining = $deadline - microtime(true);
if ($remaining <= 0) {
$result = false;
break;
}
if (\Fiber::getCurrent() !== null) {
/* Cooperative: hand the loop back between handshake
steps rather than blocking in stream_select, so the
TLS handshake with a slow mail server does not freeze
the rest of the site. The loop resumes this fiber on
its next pass and the step below is retried. */
\Fiber::suspend(['read' => $socket]);
} else {
$reads = [$socket];
$writes = [$socket];
$excepts = null;
@stream_select($reads, $writes, $excepts,
(int) $remaining,
(int) (($remaining - (int) $remaining) *
C\MICROSECONDS_PER_SECOND));
}
$result = stream_socket_enable_crypto($socket, true,
self::TLS_CRYPTO_METHOD);
}
stream_set_blocking($socket, true);
return $result === true;
}
/**
* Classifies a raw PHP-level stream error string into one of a
* small set of stable tokens (see the ERROR_* constants). The
* raw text from PHP ("SSL operation failed with code 1...",
* "Connection refused", and so on) is matched against a few
* well-known substrings. The caller (a controller, which is the
* layer permitted to localize) maps the token to a display
* message; the unrecognized case returns ERROR_CONNECT_FAILED
* with the raw detail appended after a colon so no information
* is lost.
*
* @param string $raw the captured PHP warning text or the
* $errstr out-parameter from stream_socket_client
* @return string one of the ERROR_* token strings; the cert
* tokens (ERROR_CERT_HOSTNAME, ERROR_CERT_EXPIRED,
* ERROR_CERT_VERIFY) and ERROR_CONNECT_FAILED are suffixed
* with ": <detail>" carrying the underlying reason
*/
protected static function classifyConnectError(string $raw): string
{
/*
A certificate-name mismatch is reported by PHP itself (not
the OpenSSL verify callback) and is a different problem
from an untrusted or self-signed certificate: the
certificate may be perfectly valid and trusted, just
issued for a host name other than the one configured on
the account. It is surfaced as its own token so the user
is steered toward fixing the Host field rather than
reaching for "allow self-signed certificate", which turns
off *all* peer verification (issuer trust included), not
just the name check.
*/
if (stripos($raw, 'did not match expected') !== false ||
stripos($raw, 'Hostname mismatch') !== false) {
return self::ERROR_CERT_HOSTNAME . ": " .
self::certReasonDetail($raw);
}
if (stripos($raw, 'certificate has expired') !== false ||
stripos($raw, 'certificate is not yet valid') !== false) {
return self::ERROR_CERT_EXPIRED . ": " .
self::certReasonDetail($raw);
}
$checks = [
'certificate verify failed' => self::ERROR_CERT_VERIFY,
'Failed to enable crypto' => self::ERROR_SECURE_CONNECT,
'SSL operation failed' => self::ERROR_SECURE_CONNECT,
'Connection refused' => self::ERROR_CONNECTION_REFUSED,
'timed out' => self::ERROR_TIMED_OUT,
'Operation timed out' => self::ERROR_TIMED_OUT,
'getaddrinfo' => self::ERROR_HOST_NOT_FOUND,
'Name or service not known' => self::ERROR_HOST_NOT_FOUND,
'php_network_getaddresses' => self::ERROR_HOST_NOT_FOUND,
];
foreach ($checks as $needle => $token) {
if (stripos($raw, $needle) !== false) {
/*
the generic cert-verify case still keeps whatever
reason text OpenSSL did give (self-signed,
"unable to get local issuer certificate", and so
on) so the message is not reduced to a bare token
*/
if ($token === self::ERROR_CERT_VERIFY) {
return $token . ": " . self::certReasonDetail($raw);
}
return $token;
}
}
$raw = trim($raw);
if ($raw === '') {
return self::ERROR_CONNECT_FAILED;
}
return self::ERROR_CONNECT_FAILED . ": $raw";
}
/**
* Pulls the human-meaningful part out of a raw TLS warning
* string for display after a cert-failure token. PHP's stream
* warnings are wrapped in boilerplate ("stream_socket_enable_
* crypto(): ...", "SSL operation failed with code N. OpenSSL
* Error messages: error:XXXXXXXX:SSL routines::"); this strips
* that down to the part a person can act on, falling back to a
* trimmed copy of the whole string when no known marker is
* found.
*
* @param string $raw the captured PHP warning text
* @return string a concise reason suitable for display
*/
protected static function certReasonDetail(string $raw): string
{
/*
a "Peer certificate CN=`x` did not match expected CN=`y`"
notice is already concise and the most useful thing to
show; lift it out verbatim if present
*/
if (preg_match('/Peer certificate .*did not match expected[^|]*/i',
$raw, $m)) {
return trim($m[0]);
}
/*
otherwise take the text after the last OpenSSL "routines"
marker, which is where the actual reason ("self signed
certificate", "certificate has expired", ...) lives
*/
if (preg_match('/SSL routines[^:]*:+([^|]+)$/i', $raw, $m)) {
$reason = trim($m[1]);
if ($reason !== '') {
return $reason;
}
}
return trim($raw);
}
/**
* Reads up to $bytes bytes from the SSL socket, swallowing any
* PHP-level warning fread emits when the stream is in a bad
* state. Returns whatever fread returned: a string (possibly
* empty), or false on hard failure. Callers inspect the return
* value together with stream_get_meta_data to decide whether
* to retry, throw, or treat the stream as closed.
*
* @param int $bytes maximum bytes to read in one call
* @return string|false the chunk read, or false on failure
*/
protected function socketRead(int $bytes)
{
set_error_handler(function () {
return true;
});
try {
return fread($this->socket, $bytes);
} finally {
restore_error_handler();
}
}
/**
* Writes $data to the SSL socket, swallowing any PHP-level
* warning fwrite emits when the stream is in a bad state.
* Returns whatever fwrite returned: the number of bytes
* written, or false on hard failure. Callers inspect the
* return value to decide whether to throw or surface a
* structured error.
*
* @param string $data bytes to write
* @return int|false bytes written, or false on failure
*/
protected function socketWrite(string $data)
{
set_error_handler(function () {
return true;
});
try {
return fwrite($this->socket, $data);
} finally {
restore_error_handler();
}
}
/**
* Whether the socket has something to read right now (data, or an
* end-of-file once the peer has closed), checked without blocking.
* Used by the cooperative read wait so a fiber only attempts a read
* once a read will return at once rather than block.
*
* @return bool true if a read would return immediately
*/
protected function socketReadable(): bool
{
$reads = [$this->socket];
$writes = null;
$excepts = null;
set_error_handler(function () {
return true;
});
try {
$ready = stream_select($reads, $writes, $excepts, 0);
} finally {
restore_error_handler();
}
return $ready > 0;
}
/**
* Waits until the socket can be read without blocking.
*
* Outside a fiber this returns at once and lets the ordinary blocking
* read do the waiting, so behaviour under Apache and in any
* non-cooperative caller is exactly as before. Inside a fiber it hands
* the event loop back with Fiber::suspend() until the socket is
* readable, so one slow or unresponsive mail server cannot freeze
* every other connection the single-process server is handling. The
* read that follows then returns straight away.
*
* @return void
*/
protected function waitReadable(): void
{
if (\Fiber::getCurrent() === null) {
return;
}
while (!$this->socketReadable()) {
\Fiber::suspend(['read' => $this->socket]);
}
}
/**
* Reads exactly one CRLF-terminated line from the socket, draining
* the internal buffer first and refilling it as needed. On EOF,
* sets the alive flag false and returns the empty string; callers
* (sendInner, the constructor greeting check) inspect the alive
* state to distinguish a clean empty line from a connection-loss
* EOF. Replaces the prior throw-on-EOF behavior so a dropped
* connection becomes a recoverable error the send() retry path
* can react to instead of a fatal exception that crashes the
* whole request.
*
* @return string the line content without the trailing CRLF, or
* the empty string on EOF (with isAlive() now returning false)
*/
protected function readLine(): string
{
while (!str_contains($this->buffer, "\r\n")) {
$this->waitReadable();
$chunk = $this->socketRead(4096);
if ($chunk === false || $chunk === '') {
$this->alive = false;
return '';
}
$this->buffer .= $chunk;
}
$end = strpos($this->buffer, "\r\n");
$line = substr($this->buffer, 0, $end);
$this->buffer = substr($this->buffer, $end + 2);
return $line;
}
/**
* Reads exactly $n bytes from the socket, used for IMAP literal
* {N} payloads where the wire format puts N raw bytes immediately
* after the line ending in {N}. On EOF, sets the alive flag false
* and returns whatever was buffered so far (which the sendInner
* caller treats as a partial response and reports as EOF).
*
* @param int $n number of bytes to read
* @return string the bytes that were read; may be shorter than $n
* on EOF (with isAlive() now returning false)
*/
protected function readBytes(int $n): string
{
$since_yield = 0;
while (strlen($this->buffer) < $n) {
$this->waitReadable();
$chunk = $this->socketRead(4096);
if ($chunk === false || $chunk === '') {
$this->alive = false;
$payload = $this->buffer;
$this->buffer = '';
return $payload;
}
$this->buffer .= $chunk;
$since_yield += strlen($chunk);
if ($since_yield >= self::COOPERATIVE_READ_YIELD &&
\Fiber::getCurrent() !== null) {
$since_yield = 0;
\Fiber::suspend();
}
}
$payload = substr($this->buffer, 0, $n);
$this->buffer = substr($this->buffer, $n);
return $payload;
}
/**
* Sends a tagged command and accumulates every line the server
* emits until the matching tagged response (OK / NO / BAD) arrives.
* Handles IMAP literal payloads ({N} markers) transparently by
* reading the announced bytes and folding them back into the
* line list at the index they appeared.
*
* Returns an associative array with four keys:
* - status "OK" | "NO" | "BAD" (or a single-token edge case)
* - detail the rest of the tagged response after the status
* - untagged array of every untagged "* ..." line the server
* emitted before the tagged response
* - literals array of literal payloads keyed by the untagged-
* line index they were attached to
*
* @param string $command_text the IMAP command, without tag or
* CRLF
* @param int|null $timeout when not null, the socket read
* timeout in seconds is temporarily changed to this value
* for the duration of the call; the original timeout is
* restored on return. Used by callers that issue commands
* known to be slow on large mailboxes (SORT, SEARCH, ...).
* @return array{status: string, detail: string, untagged: array,
* literals: array} parsed response
*/
public function send(string $command_text,
?int $timeout = null): array
{
if ($timeout !== null && is_resource($this->socket)) {
stream_set_timeout($this->socket, $timeout);
}
try {
$response = $this->sendInner($command_text);
if ($response['status'] === 'EOF' && !$this->alive &&
$this->reconnect_handler !== null) {
/* connection-loss EOF on the first try; invoke the
reconnect handler the backend registered. The
handler opens a fresh client, logs in, and calls
adoptSocketFrom() so $this becomes usable again.
Retry once; a second EOF surfaces as-is so the
caller can decide. */
$reconnected = ($this->reconnect_handler)($this);
if ($reconnected) {
if ($timeout !== null && is_resource(
$this->socket)) {
stream_set_timeout($this->socket, $timeout);
}
$response = $this->sendInner($command_text);
}
}
return $response;
} finally {
if ($timeout !== null && is_resource($this->socket)) {
stream_set_timeout($this->socket, $this->timeout);
}
}
}
/**
* Whether this client's socket is still usable for IMAP
* commands. False after a connection-loss EOF, after a failed
* write, and after close(). Callers that hold a long-lived
* reference can check before issuing the next command, but the
* usual pattern is to inspect the send() response shape (a
* status of 'EOF' means the call hit a dead socket and the
* reconnect handler either did not exist or failed to recover).
*
* @return bool whether the client believes its socket is alive
*/
public function isAlive(): bool
{
return $this->alive;
}
/**
* Registers a callback send() invokes when a sendInner call
* detects a connection-loss EOF. Handler signature:
* function (ImapClient $dead): bool
* The handler should open a fresh TCP / TLS / LOGIN session,
* then call $dead->adoptSocketFrom($fresh) so the caller's
* still-held reference becomes usable for the retry. Returning
* true means the retry is worth attempting; false means
* send() reports the EOF up unchanged. Passing null clears
* any previously-registered handler.
*
* @param callable|null $handler the reconnect callback or null
*/
public function setReconnectHandler(?callable $handler): void
{
$this->reconnect_handler = $handler;
}
/**
* Moves the socket and protocol state out of a freshly-opened
* sibling ImapClient and into this one, so a caller that
* holds a long-lived reference to the dead client can keep
* using it after a reconnect. Used only by the reconnect
* handler registered through setReconnectHandler. Resets the
* line buffer (a partial response from the dead session would
* confuse the next parse) and bumps the tag counter past any
* tags the fresh client may have issued during its own
* authentication so subsequent send() calls mint untaken
* tags. The donor's socket reference is cleared so its
* destructor does not close the now-shared file descriptor.
*
* @param self $fresh a freshly-opened, logged-in ImapClient
* whose socket should be taken over by $this
*/
public function adoptSocketFrom(self $fresh): void
{
$this->socket = $fresh->socket;
/* Take the fresh client's read-buffer too: its
constructor consumed the greeting line, but any extra
bytes the server sent in the same TCP segment (untagged
CAPABILITY notification, etc.) live there and would be
lost if we cleared. */
$this->buffer = $fresh->buffer;
if ($fresh->tag_counter > $this->tag_counter) {
$this->tag_counter = $fresh->tag_counter;
}
$this->alive = $fresh->alive;
$fresh->socket = null;
$fresh->buffer = '';
$fresh->alive = false;
}
/**
* Inner part of send() that holds the read loop. Split out so
* the per-call timeout-override wrapper can sit cleanly around
* it and always restore the original socket timeout, even on
* an exception path.
*
* @param string $command_text the IMAP command, without tag or
* CRLF
* @return array{status: string, detail: string, untagged: array,
* literals: array} parsed response
*/
protected function sendInner(string $command_text): array
{
$tag = $this->issueCommand($command_text);
if ($tag === null) {
return ['status' => 'EOF',
'detail' => 'write failed',
'untagged' => [], 'literals' => []];
}
return $this->collectResponse($tag);
}
/**
* Writes a tagged command onto the socket and returns the
* tag used, without reading any response. Pairs with
* collectResponse(), which the caller invokes later to
* read the server's reply. The two-step shape lets a
* caller pipeline work that is independent of the
* server's response (typically a second IMAP connection's
* read, or local CPU / disk work) while the server is
* busy preparing the result.
*
* Returns null when the socket is dead or the write
* fails; the caller treats that the same way it treats a
* status==='EOF' response from send().
*
* @param string $command_text the IMAP command, without
* tag or CRLF
* @return string|null the tag minted for this command,
* or null on a socket-write failure
*/
public function issueCommand(string $command_text): ?string
{
if (!$this->alive || !is_resource($this->socket)) {
return null;
}
$tag = 'X' . (++$this->tag_counter);
$line = "$tag $command_text\r\n";
if ($this->socketWrite($line) === false) {
$this->alive = false;
return null;
}
return $tag;
}
/**
* Reads from the socket until the tagged response for
* $tag arrives, returning the same shape send() returns.
* Pairs with issueCommand(). The caller is responsible
* for invoking this exactly once per issued tag, in the
* order the commands were issued (RFC 3501 ยง5.5 allows a
* server to reorder some responses, but for a single
* connection in our use the tagged status lines come
* back in issue order).
*
* @param string $tag the value returned by issueCommand
* @return array{status: string, detail: string,
* untagged: array, literals: array} parsed response
*/
public function collectResponse(string $tag): array
{
$untagged = [];
$literals = [];
while (true) {
$line = $this->readLine();
if (!$this->alive) {
return ['status' => 'EOF',
'detail' => 'read failed mid-response',
'untagged' => $untagged,
'literals' => $literals];
}
/* A trailing "{N}" announces N bytes of literal payload
immediately following on the wire; swallow it into
$literals keyed by the untagged-line index, then read
the continuation line that closes the logical line.
*/
if (preg_match('/\{(\d+)\}$/', $line, $m)) {
$payload = $this->readBytes((int) $m[1]);
if (!$this->alive) {
return ['status' => 'EOF',
'detail' => 'read failed mid-literal',
'untagged' => $untagged,
'literals' => $literals];
}
$idx = count($untagged);
$literals[$idx] = $payload;
$continuation = $this->readLine();
if (!$this->alive) {
return ['status' => 'EOF',
'detail' =>
'read failed after literal payload',
'untagged' => $untagged,
'literals' => $literals];
}
$untagged[] = $line . $payload . $continuation;
continue;
}
if (str_starts_with($line, "$tag ")) {
$rest = substr($line, strlen($tag) + 1);
$space = strpos($rest, ' ');
if ($space === false) {
return ['status' => $rest, 'detail' => '',
'untagged' => $untagged,
'literals' => $literals];
}
return ['status' => substr($rest, 0, $space),
'detail' => substr($rest, $space + 1),
'untagged' => $untagged,
'literals' => $literals];
}
$untagged[] = $line;
}
}
/**
* Appends a message into a folder via the IMAP APPEND command
* (RFC 3501 ยง6.3.11). The exchange is: send "APPEND folder
* flags {N}", read a "+" continuation response from the
* server, write the N message bytes followed by CRLF, then
* read the final tagged response.
*
* @param string $folder destination folder name; will be
* quoted if it contains spaces, brackets, or other
* characters that aren't safe as a bare atom
* @param string $flags parenthesized flag list to set on the
* appended message, e.g. "(\\Seen)" for sent mail. Empty
* string omits the flags slot entirely.
* @param string $message RFC822 message bytes with CRLF line
* endings; no trailing CRLF-dot
* @return array{ok: bool, detail: string} ok=true on a
* tagged OK response, false on NO/BAD or socket error.
* detail carries the server's response text (or local
* error description) for logging.
*/
public function appendMessage(string $folder, string $flags,
string $message): array
{
$quoted = $this->quoteAtom($folder);
$flag_part = ($flags === '') ? '' : (' ' . $flags);
$bytes = strlen($message);
$tag = 'X' . (++$this->tag_counter);
$announce = "$tag APPEND $quoted$flag_part {" . $bytes .
"}\r\n";
if ($this->socketWrite($announce) === false) {
$this->alive = false;
return ['ok' => false,
'detail' => 'IMAP APPEND write failed'];
}
$continuation = $this->readLine();
if (!$this->alive) {
return ['ok' => false,
'detail' => 'IMAP APPEND read failed before +'];
}
if (!str_starts_with($continuation, '+')) {
return ['ok' => false, 'detail' =>
'expected + continuation, got: ' .
trim($continuation)];
}
if ($this->socketWrite($message . "\r\n") === false) {
$this->alive = false;
return ['ok' => false,
'detail' => 'IMAP APPEND literal write failed'];
}
while (true) {
$line = $this->readLine();
if (!$this->alive) {
return ['ok' => false,
'detail' =>
'IMAP APPEND read failed mid-response'];
}
if (str_starts_with($line, "$tag ")) {
$rest = substr($line, strlen($tag) + 1);
$space = strpos($rest, ' ');
$status = ($space === false) ? trim($rest) :
substr($rest, 0, $space);
$detail = ($space === false) ? '' :
trim(substr($rest, $space + 1));
return ['ok' => ($status === 'OK'),
'detail' => $detail];
}
/* untagged lines (e.g. * 5 EXISTS, * OK [...] notes)
are informational; keep reading until the tagged
final response. */
}
}
/**
* Returns an IMAP-safe representation of an atom for use in a
* command. Bare atoms (alphanumerics, period, hyphen,
* underscore) pass through; anything else is double-quoted
* with backslash escaping per RFC 3501 ยง4.3.
*
* @param string $atom raw value
* @return string quoted or bare atom
*/
protected function quoteAtom(string $atom): string
{
if (preg_match('/^[A-Za-z0-9._-]+$/', $atom)) {
return $atom;
}
return '"' . str_replace(['\\', '"'],
['\\\\', '\\"'], $atom) . '"';
}
/**
* Closes the IMAP session politely by sending LOGOUT and then
* dropping the socket. Failures are swallowed; the session is
* about to die anyway.
*/
public function close(): void
{
if (!is_resource($this->socket)) {
$this->alive = false;
return;
}
/* clear the reconnect handler first: send("LOGOUT") below
would otherwise trigger a pointless reconnect-and-retry
if the server has already half-closed the session, and
we are tearing the client down regardless. */
$this->reconnect_handler = null;
try {
$this->send("LOGOUT");
} catch (\Exception $exception) {
/* session may already be half-closed; nothing to
recover. */
}
if (is_resource($this->socket)) {
fclose($this->socket);
$this->socket = null;
}
$this->alive = false;
}
}