setNoValidateCert($novalidatecert); if ($host) { $this->connect($host, $port, $ssl); } } /** * Public destructor */ public function __destruct() { $this->logout(); } /** * Open connection to IMAP server * * @param string $host hostname or IP address of IMAP server * @param int|null $port of IMAP server, default is 143 (993 for ssl) * @param string|bool $ssl use 'SSL', 'TLS' or false * @throws Exception\RuntimeException * @return void */ public function connect($host, $port = null, $ssl = false) { $transport = 'tcp'; $isTls = false; if ($ssl) { $ssl = strtolower($ssl); } switch ($ssl) { case 'ssl': $transport = 'ssl'; if (! $port) { $port = 993; } break; case 'tls': $isTls = true; // break intentionally omitted default: if (! $port) { $port = 143; } } $this->socket = $this->setupSocket($transport, $host, $port, self::TIMEOUT_CONNECTION); if (! $this->assumedNextLine('* OK')) { throw new Exception\RuntimeException('host doesn\'t allow connection'); } if ($isTls) { $result = $this->requestAndResponse('STARTTLS'); $result = $result && stream_socket_enable_crypto($this->socket, true, $this->getCryptoMethod()); if (! $result) { throw new Exception\RuntimeException('cannot enable TLS'); } } } /** * get the next line from socket with error checking, but nothing else * * @throws Exception\RuntimeException * @return string next line */ protected function nextLine() { $line = fgets($this->socket); if ($line === false) { throw new Exception\RuntimeException('cannot read - connection closed?'); } return $line; } /** * get next line and assume it starts with $start. some requests give a simple * feedback so we can quickly check if we can go on. * * @param string $start the first bytes we assume to be in the next line * @return bool line starts with $start */ protected function assumedNextLine($start) { $line = $this->nextLine(); return str_starts_with($line, $start); } /** * get next line and split the tag. that's the normal case for a response line * * @param string $tag tag of line is returned by reference * @return string next line */ protected function nextTaggedLine(&$tag) { $line = $this->nextLine(); // separate tag from line [$tag, $line] = explode(' ', $line, 2); return $line; } /** * split a given line in tokens. a token is literal of any form or a list * * @param string $line line to decode * @return array tokens, literals are returned as string, lists as array */ protected function decodeLine($line) { $tokens = []; $stack = []; /* We start to decode the response here. The understood tokens are: literal "literal" or also "lit\\er\"al" {bytes}literal (literals*) All tokens are returned in an array. Literals in braces (the last understood token in the list) are returned as an array of tokens. I.e. the following response: "foo" baz {3}bar ("f\\\"oo" bar) would be returned as: array('foo', 'baz', 'bar', array('f\\\"oo', 'bar')); // TODO: add handling of '[' and ']' to parser for easier handling of response text */ // replace any trailing including spaces with a single space $line = rtrim($line) . ' '; while (($pos = strpos($line, ' ')) !== false) { $token = substr($line, 0, $pos); if (! strlen($token)) { continue; } while ($token[0] == '(') { array_push($stack, $tokens); $tokens = []; $token = substr($token, 1); } if ($token[0] == '"') { if (preg_match('%^\(*"((.|\\\\|\\")*?)" *%', $line, $matches)) { $tokens[] = $matches[1]; $line = substr($line, strlen($matches[0])); continue; } } if ($token[0] == '{') { $endPos = strpos($token, '}'); $chars = substr($token, 1, $endPos - 1); if (is_numeric($chars)) { $token = ''; while (strlen($token) < $chars) { $token .= $this->nextLine(); } $line = ''; if (strlen($token) > $chars) { $line = substr($token, $chars); $token = substr($token, 0, $chars); } else { $line .= $this->nextLine(); } $tokens[] = $token; $line = trim($line) . ' '; continue; } } if ($stack && $token[strlen($token) - 1] == ')') { // closing braces are not separated by spaces, so we need to count them $braces = strlen($token); $token = rtrim($token, ')'); // only count braces if more than one $braces -= strlen($token) + 1; // only add if token had more than just closing braces if (rtrim($token) != '') { $tokens[] = rtrim($token); } $token = $tokens; $tokens = array_pop($stack); // special handline if more than one closing brace while ($braces-- > 0) { $tokens[] = $token; $token = $tokens; $tokens = array_pop($stack); } } $tokens[] = $token; $line = substr($line, $pos + 1); } // maybe the server forgot to send some closing braces while ($stack) { $child = $tokens; $tokens = array_pop($stack); $tokens[] = $child; } return $tokens; } /** * read a response "line" (could also be more than one real line if response has {..}) * and do a simple decode * * @param array|string $tokens decoded tokens are returned by reference, if $dontParse * is true the unparsed line is returned here * @param string $wantedTag check for this tag for response code. Default '*' is * continuation tag. * @param bool $dontParse if true only the unparsed line is returned $tokens * @return bool if returned tag matches wanted tag */ public function readLine(&$tokens = [], $wantedTag = '*', $dontParse = false) { $tag = null; // define $tag variable before first use $line = $this->nextTaggedLine($tag); // get next tag if (! $dontParse) { $tokens = $this->decodeLine($line); } else { $tokens = $line; } // if tag is wanted tag we might be at the end of a multiline response return $tag == $wantedTag; } /** * read all lines of response until given tag is found (last line of response) * * @param string $tag the tag of your request * @param bool $dontParse if true every line is returned unparsed instead of * the decoded tokens * @return null|bool|array tokens if success, false if error, null if bad request */ public function readResponse($tag, $dontParse = false) { $lines = []; $tokens = null; // define $tokens variable before first use while (! $this->readLine($tokens, $tag, $dontParse)) { $lines[] = $tokens; } if ($dontParse) { // last to chars are still needed for response code $tokens = [substr($tokens, 0, 2)]; } // last line has response code if ($tokens[0] == 'OK') { return $lines ?: true; } elseif ($tokens[0] == 'NO') { return false; } } /** * send a request * * @param string $command your request command * @param array $tokens additional parameters to command, use escapeString() to prepare * @param string $tag provide a tag otherwise an autogenerated is returned * @throws Exception\RuntimeException */ public function sendRequest($command, $tokens = [], &$tag = null) { if (! $tag) { ++$this->tagCount; $tag = 'TAG' . $this->tagCount; } $line = $tag . ' ' . $command; foreach ($tokens as $token) { if (is_array($token)) { if (fwrite($this->socket, $line . ' ' . $token[0] . "\r\n") === false) { throw new Exception\RuntimeException('cannot write - connection closed?'); } if (! $this->assumedNextLine('+ ')) { throw new Exception\RuntimeException('cannot send literal string'); } $line = $token[1]; } else { $line .= ' ' . $token; } } if (fwrite($this->socket, $line . "\r\n") === false) { throw new Exception\RuntimeException('cannot write - connection closed?'); } } /** * send a request and get response at once * * @param string $command command as in sendRequest() * @param array $tokens parameters as in sendRequest() * @param bool $dontParse if true unparsed lines are returned instead of tokens * @return mixed response as in readResponse() */ public function requestAndResponse($command, $tokens = [], $dontParse = false) { $tag = null; // define $tag variable before first use $this->sendRequest($command, $tokens, $tag); return $this->readResponse($tag, $dontParse); } /** * escape one or more literals i.e. for sendRequest * * @param string $string the literal/-s * @return string|array escape literals, literals with newline ar returned * as array('{size}', 'string'); */ public function escapeString($string) { if (func_num_args() < 2) { if (str_contains($string, "\n")) { return ['{' . strlen($string) . '}', $string]; } return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $string) . '"'; } $result = []; foreach (func_get_args() as $string) { $result[] = $this->escapeString($string); } return $result; } /** * escape a list with literals or lists * * @param array $list list with literals or lists as PHP array * @return string escaped list for imap */ public function escapeList($list) { $result = []; foreach ($list as $v) { if (! is_array($v)) { $result[] = $v; continue; } $result[] = $this->escapeList($v); } return '(' . implode(' ', $result) . ')'; } /** * Login to IMAP server. * * @param string $user username * @param string $password password * @return bool success */ public function login($user, $password) { return $this->requestAndResponse('LOGIN', $this->escapeString($user, $password), true); } /** * logout of imap server * * @return bool success */ public function logout() { $result = false; if ($this->socket) { try { $result = $this->requestAndResponse('LOGOUT', [], true); } catch (Exception\ExceptionInterface) { // ignoring exception } fclose($this->socket); $this->socket = null; } return $result; } /** * Get capabilities from IMAP server * * @return array list of capabilities * @throws ExceptionInterface */ public function capability() { $response = $this->requestAndResponse('CAPABILITY'); if (! $response) { return []; } $capabilities = []; foreach ($response as $line) { $capabilities = array_merge($capabilities, $line); } return $capabilities; } /** * Examine and select have the same response. The common code for both * is in this method * * @param string $command can be 'EXAMINE' or 'SELECT' and this is used as command * @param string $box which folder to change to or examine * @return bool|array false if error, array with returned information * otherwise (flags, exists, recent, uidvalidity) * @throws ExceptionInterface */ public function examineOrSelect($command = 'EXAMINE', $box = 'INBOX') { $tag = null; // define $tag variable before first use $this->sendRequest($command, [$this->escapeString($box)], $tag); $result = []; $tokens = null; // define $tokens variable before first use while (! $this->readLine($tokens, $tag)) { if ($tokens[0] == 'FLAGS') { array_shift($tokens); $result['flags'] = $tokens; continue; } switch ($tokens[1]) { case 'EXISTS': case 'RECENT': $result[strtolower($tokens[1])] = $tokens[0]; break; case '[UIDVALIDITY': $result['uidvalidity'] = (int) $tokens[2]; break; default: // ignore } } if ($tokens[0] != 'OK') { return false; } return $result; } /** * change folder * * @param string $box change to this folder * @return bool|array see examineOrselect() * @throws ExceptionInterface */ public function select($box = 'INBOX') { return $this->examineOrSelect('SELECT', $box); } /** * examine folder * * @param string $box examine this folder * @return bool|array see examineOrselect() * @throws ExceptionInterface */ public function examine($box = 'INBOX') { return $this->examineOrSelect('EXAMINE', $box); } /** * fetch one or more items of one or more messages * * @param string|array $items items to fetch from message(s) as string (if only one item) * or array of strings * @param int|array $from message for items or start message if $to !== null * @param int|null $to if null only one message ($from) is fetched, else it's the * last message, INF means last message available * @param bool $uid set to true if passing a unique id * @throws Exception\RuntimeException * @return string|array if only one item of one message is fetched it's returned as string * if items of one message are fetched it's returned as (name => value) * if one items of messages are fetched it's returned as (msgno => value) * if items of messages are fetched it's returned as (msgno => (name => value)) */ public function fetch($items, $from, $to = null, $uid = false) { if (is_array($from)) { $set = implode(',', $from); } elseif ($to === null) { $set = (int) $from; } elseif ($to === INF) { $set = (int) $from . ':*'; } else { $set = (int) $from . ':' . (int) $to; } $items = (array) $items; $itemList = $this->escapeList($items); $tag = null; // define $tag variable before first use $this->sendRequest(($uid ? 'UID ' : '') . 'FETCH', [$set, $itemList], $tag); $result = []; $tokens = null; // define $tokens variable before first use while (! $this->readLine($tokens, $tag)) { // ignore other responses if ($tokens[1] != 'FETCH') { continue; } // find array key of UID value; try the last elements, or search for it if ($uid) { $count = count($tokens[2]); if ($tokens[2][$count - 2] == 'UID') { $uidKey = $count - 1; } else { $uidKey = array_search('UID', $tokens[2]) + 1; } } // ignore other messages if ($to === null && ! is_array($from) && ($uid ? $tokens[2][$uidKey] != $from : $tokens[0] != $from)) { continue; } // if we only want one item we return that one directly if (count($items) == 1) { if ($tokens[2][0] == $items[0]) { $data = $tokens[2][1]; } elseif ($uid && $tokens[2][2] == $items[0]) { $data = $tokens[2][3]; } else { // maybe the server send an other field we didn't wanted $count = count($tokens[2]); // we start with 2, because 0 was already checked for ($i = 2; $i < $count; $i += 2) { if ($tokens[2][$i] != $items[0]) { continue; } $data = $tokens[2][$i + 1]; break; } } } else { $data = []; while (key($tokens[2]) !== null) { $data[current($tokens[2])] = next($tokens[2]); next($tokens[2]); } } // if we want only one message we can ignore everything else and just return if ($to === null && ! is_array($from) && ($uid ? $tokens[2][$uidKey] == $from : $tokens[0] == $from)) { // we still need to read all lines // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedWhile while (! $this->readLine($tokens, $tag)) { } return $data; } $result[$tokens[0]] = $data; } if ($to === null && ! is_array($from)) { throw new Exception\RuntimeException('the single id was not found in response'); } return $result; } /** * get mailbox list * * this method can't be named after the IMAP command 'LIST', as list is a reserved keyword * * @param string $reference mailbox reference for list * @param string $mailbox mailbox name match with wildcards * @return array mailboxes that matched $mailbox as array(globalName => array('delim' => .., 'flags' => ..)) * @throws ExceptionInterface */ public function listMailbox($reference = '', $mailbox = '*') { $result = []; $list = $this->requestAndResponse('LIST', $this->escapeString($reference, $mailbox)); if (! $list || $list === true) { return $result; } foreach ($list as $item) { if (count($item) != 4 || $item[0] != 'LIST') { continue; } $result[$item[3]] = ['delim' => $item[2], 'flags' => $item[1]]; } return $result; } /** * set flags * * @param array $flags flags to set, add or remove - see $mode * @param int $from message for items or start message if $to !== null * @param int|null $to if null only one message ($from) is fetched, else it's the * last message, INF means last message available * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given * @param bool $silent if false the return values are the new flags for the wanted messages * @return bool|array new flags if $silent is false, else true or false depending on success * @throws ExceptionInterface */ public function store(array $flags, $from, $to = null, $mode = null, $silent = true) { $item = 'FLAGS'; if ($mode == '+' || $mode == '-') { $item = $mode . $item; } if ($silent) { $item .= '.SILENT'; } $flags = $this->escapeList($flags); $set = (int) $from; if ($to !== null) { $set .= ':' . ($to == INF ? '*' : (int) $to); } $result = $this->requestAndResponse('STORE', [$set, $item, $flags], $silent); if ($silent) { return (bool) $result; } $tokens = $result; $result = []; foreach ($tokens as $token) { if ($token[1] != 'FETCH' || $token[2][0] != 'FLAGS') { continue; } $result[$token[0]] = $token[2][1]; } return $result; } /** * append a new message to given folder * * @param string $folder name of target folder * @param string $message full message content * @param array $flags flags for new message * @param string $date date for new message * @return bool success * @throws ExceptionInterface */ public function append($folder, $message, $flags = null, $date = null) { $tokens = []; $tokens[] = $this->escapeString($folder); if ($flags !== null) { $tokens[] = $this->escapeList($flags); } if ($date !== null) { $tokens[] = $this->escapeString($date); } $tokens[] = $this->escapeString($message); return $this->requestAndResponse('APPEND', $tokens, true); } /** * copy message set from current folder to other folder * * @param string $folder destination folder * @param int $from * @param int|null $to if null only one message ($from) is fetched, else it's the * last message, INF means last message available * @return bool success */ public function copy($folder, $from, $to = null) { $set = (string) $from; if ($to !== null) { $set .= ':' . ($to == INF ? '*' : (int) $to); } return $this->requestAndResponse('COPY', [$set, $this->escapeString($folder)], true); } /** * create a new folder (and parent folders if needed) * * @param string $folder folder name * @return bool success */ public function create($folder) { return $this->requestAndResponse('CREATE', [$this->escapeString($folder)], true); } /** * rename an existing folder * * @param string $old old name * @param string $new new name * @return bool success */ public function rename($old, $new) { return $this->requestAndResponse('RENAME', $this->escapeString($old, $new), true); } /** * remove a folder * * @param string $folder folder name * @return bool success */ public function delete($folder) { return $this->requestAndResponse('DELETE', [$this->escapeString($folder)], true); } /** * subscribe to a folder * * @param string $folder folder name * @return bool success */ public function subscribe($folder) { return $this->requestAndResponse('SUBSCRIBE', [$this->escapeString($folder)], true); } /** * permanently remove messages * * @return bool success */ public function expunge() { // TODO: parse response? return $this->requestAndResponse('EXPUNGE'); } /** * send noop * * @return bool success */ public function noop() { // TODO: parse response return $this->requestAndResponse('NOOP'); } /** * do a search request * * This method is currently marked as internal as the API might change and is not * safe if you don't take precautions. * * @param array $params * @return array message ids */ public function search(array $params) { $response = $this->requestAndResponse('SEARCH', $params); if (! $response) { return $response; } foreach ($response as $ids) { if ($ids[0] == 'SEARCH') { array_shift($ids); return $ids; } } return []; } }