File: //usr/local/CyberCP/public/snappymail/snappymail/v/2.38.2/app/libraries/MailSo/Imap/ImapClient.php
<?php
/*
* This file is part of MailSo.
*
* (c) 2014 Usenko Timur
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace MailSo\Imap;
use MailSo\Net\Enumerations\ConnectionSecurityType;
/**
* @category MailSo
* @package Imap
*/
class ImapClient extends \MailSo\Net\NetClient
{
use Traits\ResponseParser;
use Commands\ACL;
use Commands\Folders;
use Commands\Messages;
use Commands\Metadata;
use Commands\Quota;
public string $TAG_PREFIX = 'TAG';
private int $iTagCount = 0;
private ?array $aCapa = null;
private ?array $aCapaRaw = null;
private ?FolderInformation $oCurrentFolderInfo = null;
/**
* Used by \MailSo\Mail\MailClient::MessageMimeStream
*/
private array $aFetchCallbacks = array();
private array $aTagTimeouts = array();
private bool $bIsLoggined = false;
/**
* RFC 6855 UTF8 mode
*/
private bool $UTF8 = false;
public function Hash() : string
{
return \md5('ImapClientHash/'.
$this->Settings->username . '@' .
$this->Settings->host . ':' .
$this->Settings->port
);
}
/**
* @throws \InvalidArgumentException
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
// public function Connect(Settings $oSettings) : void
public function Connect(\MailSo\Net\ConnectSettings $oSettings) : void
{
$this->aTagTimeouts['*'] = \microtime(true);
parent::Connect($oSettings);
$this->setCapabilities($this->getResponse('*'));
if (ConnectionSecurityType::STARTTLS === $this->Settings->type
|| (ConnectionSecurityType::AUTO_DETECT === $this->Settings->type && $this->hasCapability('STARTTLS'))) {
$this->StartTLS();
}
}
private function StartTLS() : void
{
if ($this->hasCapability('STARTTLS')) {
$this->SendRequestGetResponse('STARTTLS');
$this->EnableCrypto();
$this->aCapa = null;
$this->aCapaRaw = null;
} else {
$this->writeLogException(
new \MailSo\Net\Exceptions\SocketUnsuppoterdSecureConnectionException('STARTTLS is not supported'),
\LOG_ERR);
}
}
public function supportsAuthType(string $sasl_type) : bool
{
return $this->hasCapability("AUTH={$sasl_type}");
}
/**
* @throws \UnexpectedValueException
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Login(Settings $oSettings) : self
{
if ($this->bIsLoggined) {
return $this;
}
$sLogin = $oSettings->username;
$sPassword = $oSettings->passphrase;
if (!\strlen($sLogin) || !\strlen($sPassword)) {
$this->writeLogException(new \UnexpectedValueException, \LOG_ERR);
}
$type = '';
foreach ($oSettings->SASLMechanisms as $sasl_type) {
if ($this->hasCapability("AUTH={$sasl_type}") && \SnappyMail\SASL::isSupported($sasl_type)) {
$type = $sasl_type;
break;
}
}
// RFC3501 6.2.3
if (!$type && \in_array('LOGIN', $oSettings->SASLMechanisms) && !$this->hasCapability('LOGINDISABLED')) {
$type = 'LOGIN';
}
if (!$type) {
if (!$this->Encrypted() && $this->hasCapability('STARTTLS')) {
$this->StartTLS();
return $this->Login($oSettings);
}
throw new \MailSo\RuntimeException('No supported SASL mechanism found, remote server wants: '
. \implode(', ', \array_filter($this->Capability() ?: [], function($var){
return \str_starts_with($var, 'AUTH=');
}))
);
}
$SASL = \SnappyMail\SASL::factory($type);
try
{
if ('CRAM-MD5' === $type)
{
$oResponse = $this->SendRequestGetResponse('AUTHENTICATE', array($type));
$sChallenge = $this->getResponseValue($oResponse, Enumerations\ResponseType::CONTINUATION);
$sAuth = $SASL->authenticate($sLogin, $sPassword, $sChallenge);
$this->logMask($sAuth);
$this->sendRaw($sAuth);
$oResponse = $this->getResponse();
}
else if ('PLAIN' === $type || 'OAUTHBEARER' === $type || \str_starts_with($type, 'SCRAM-') /*|| 'PLAIN-CLIENTTOKEN' === $type*/)
{
$sAuth = $SASL->authenticate($sLogin, $sPassword);
$this->logMask($sAuth);
if ($this->hasCapability('SASL-IR')) {
$this->SendRequest('AUTHENTICATE', array($type, $sAuth));
} else {
$this->SendRequestGetResponse('AUTHENTICATE', array($type));
$this->sendRaw($sAuth);
}
$oResponse = $this->getResponse();
// if ($oResponse->getLast()->ResponseType === Enumerations\ResponseType::CONTINUATION)
if ($SASL->hasChallenge()) {
$sChallenge = $SASL->challenge($this->getResponseValue($oResponse, Enumerations\ResponseType::CONTINUATION));
$this->logMask($sChallenge);
$this->sendRaw($sChallenge);
$oResponse = $this->getResponse();
$SASL->verify($this->getResponseValue($oResponse, Enumerations\ResponseType::CONTINUATION));
$this->sendRaw('');
$oResponse = $this->getResponse();
}
}
else if ('XOAUTH2' === $type || 'OAUTHBEARER' === $type)
{
$sAuth = $SASL->authenticate($sLogin, $sPassword);
$this->logMask($sAuth);
$oResponse = $this->SendRequestGetResponse('AUTHENTICATE', array($type, $sAuth));
$oR = $oResponse->getLast();
if ($oR && Enumerations\ResponseType::CONTINUATION === $oR->ResponseType) {
if (!empty($oR->ResponseList[1]) && \preg_match('/^[a-zA-Z0-9=+\/]+$/', $oR->ResponseList[1])) {
$this->logWrite(\base64_decode($oR->ResponseList[1]), \LOG_WARNING);
}
$this->sendRaw('');
$oResponse = $this->getResponse();
}
}
else if ($this->hasCapability('LOGINDISABLED'))
{
$oResponse = $this->SendRequestGetResponse('AUTHENTICATE', array($type));
$sB64 = $this->getResponseValue($oResponse, Enumerations\ResponseType::CONTINUATION);
$sAuth = $SASL->authenticate($sLogin, $sPassword, $sB64);
$this->logMask($sAuth);
$this->sendRaw($sAuth, true);
$this->getResponse();
$sPass = $SASL->challenge(''/*UGFzc3dvcmQ6*/);
$this->logMask($sPass);
$this->sendRaw($sPass);
$oResponse = $this->getResponse();
}
else
{
$sPassword = $this->EscapeString(\mb_convert_encoding($sPassword, 'ISO-8859-1', 'UTF-8'));
$this->logMask($sPassword);
$oResponse = $this->SendRequestGetResponse('LOGIN',
array(
$this->EscapeString($sLogin),
$sPassword
));
}
$this->setCapabilities($oResponse);
/*
// TODO: RFC 9051
if ($this->hasCapability('IMAP4rev2')) {
$this->Enable('IMAP4rev1');
}
*/
// RFC 6855 || RFC 5738
$this->UTF8 = $this->hasCapability('UTF8=ONLY') || $this->hasCapability('UTF8=ACCEPT');
if ($this->UTF8) {
$this->Enable('UTF8=ACCEPT');
}
}
catch (Exceptions\NegativeResponseException $oException)
{
$this->writeLogException(new Exceptions\LoginBadCredentialsException($oException->GetResponses(), '', 0, $oException));
}
$this->bIsLoggined = true;
return $this;
}
/**
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Logout() : void
{
if ($this->bIsLoggined) {
$this->bIsLoggined = false;
$this->SendRequestGetResponse('LOGOUT');
}
}
public function IsLoggined() : bool
{
return $this->IsConnected() && $this->bIsLoggined;
}
public function IsSelected() : bool
{
return $this->IsLoggined() && $this->oCurrentFolderInfo;
}
/**
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Capability() : ?array
{
if (!$this->aCapaRaw) {
$this->setCapabilities($this->SendRequestGetResponse('CAPABILITY'));
}
/*
$this->aCapa[] = 'X-DOVECOT';
*/
return $this->aCapa;
}
public function Capabilities() : ?array
{
if (!$this->aCapaRaw) {
$this->Capability();
}
return $this->aCapaRaw;
}
public function CapabilityValue(string $sExtentionName) : ?string
{
$sExtentionName = \trim($sExtentionName) . '=';
$aCapabilities = $this->Capability() ?: [];
foreach ($aCapabilities as $string) {
if (\str_starts_with($string, $sExtentionName)) {
return \substr($string, \strlen($sExtentionName));
}
}
return null;
}
private function setCapabilities(ResponseCollection $oResponseCollection) : void
{
$aList = $oResponseCollection->getCapabilityResult();
$this->aCapaRaw = $aList;
if ($aList) {
// Strip unused capabilities
$aList = \array_diff($aList, ['PREVIEW=FUZZY', 'SNIPPET=FUZZY', 'SORT=DISPLAY']);
// Set raw response capabilities
$this->aCapaRaw = $aList;
// Set active capabilities
$aList = \array_diff($aList, $this->Settings->disabled_capabilities);
if (\in_array('THREAD', $this->Settings->disabled_capabilities)) {
$aList = \array_filter($aList, function ($item) { return !\str_starts_with($item, 'THREAD='); });
}
}
$this->aCapa = $aList;
}
/**
* Test support for things like:
* IMAP4rev1 IMAP4rev2 ID UIDPLUS QUOTA ENABLE IDLE
* SORT SORT=DISPLAY ESEARCH ESORT SEARCHRES WITHIN
* THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND
* URL-PARTIAL CATENATE UNSELECT NAMESPACE CONDSTORE
* CHILDREN LIST-EXTENDED LIST-STATUS STATUS=SIZE
* I18NLEVEL=1 QRESYNC CONTEXT=SEARCH
* BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY LITERAL+ NOTIFY
* AUTH= LOGIN LOGINDISABLED LOGIN-REFERRALS SASL-IR STARTTLS
* METADATA METADATA-SERVER SPECIAL-USE
*
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function hasCapability(string $sExtentionName) : bool
{
$sExtentionName = \trim($sExtentionName);
return $sExtentionName && \in_array(\strtoupper($sExtentionName), $this->Capability() ?: []);
}
/**
* RFC 5161
*/
public function Enable(/*string|array*/ $mCapabilityNames) : void
{
if (\is_string($mCapabilityNames)) {
$mCapabilityNames = [$mCapabilityNames];
}
if (\is_array($mCapabilityNames) /*&& $this->hasCapability('ENABLE')*/) {
$this->SendRequestGetResponse('ENABLE', $mCapabilityNames);
}
}
/**
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function GetNamespaces() : ?NamespaceResult
{
if (!$this->hasCapability('NAMESPACE')) {
return null;
}
try {
$oResponseCollection = $this->SendRequestGetResponse('NAMESPACE');
foreach ($oResponseCollection as $oResponse) {
if (Enumerations\ResponseType::UNTAGGED === $oResponse->ResponseType
&& 'NAMESPACE' === $oResponse->StatusOrIndex)
{
return new NamespaceResult($oResponse);
}
}
throw new Exceptions\ResponseException;
} catch (\Throwable $e) {
$this->writeLogException($e, \LOG_ERR);
}
}
/**
* RFC 7889
* APPENDLIMIT=<number> indicates that the IMAP server has the same upload limit for all mailboxes.
* APPENDLIMIT without any value indicates that the IMAP server supports this extension,
* and that the client will need to discover upload limits for each mailbox,
* as they might differ from mailbox to mailbox.
*/
public function AppendLimit() : ?int
{
$string = $this->CapabilityValue('APPENDLIMIT');
return \is_null($string) ? null : (int) $string;
}
/**
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function Noop() : self
{
$this->SendRequestGetResponse('NOOP');
return $this;
}
/**
* @throws \ValueError
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function SendRequest(string $sCommand, array $aParams = array(), bool $bBreakOnLiteral = false) : string
{
$sCommand = \trim($sCommand);
if (!\strlen($sCommand)) {
$this->writeLogException(new \ValueError, \LOG_ERR);
}
$this->IsConnected(true);
$sTag = $this->getNewTag();
$sRealCommand = $sTag.' '.$sCommand.$this->prepareParamLine($aParams);
$this->aTagTimeouts[$sTag] = \microtime(true);
if ($bBreakOnLiteral && !\preg_match('/\d\+\}\r\n/', $sRealCommand)) {
$iPos = \strpos($sRealCommand, "}\r\n");
if (false !== $iPos) {
$this->sendRaw(\substr($sRealCommand, 0, $iPos + 1));
return \substr($sRealCommand, $iPos + 3);
}
}
$this->sendRaw($sRealCommand);
return '';
}
/**
* @throws \ValueError
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
public function SendRequestGetResponse(string $sCommand, array $aParams = array()) : ResponseCollection
{
$this->SendRequest($sCommand, $aParams);
return $this->getResponse();
}
protected function getResponseValue(ResponseCollection $oResponseCollection, int $type = 0) : string
{
$oResponse = $oResponseCollection->getLast();
if ($oResponse && (!$type || $type === $oResponse->ResponseType)) {
$sResult = $oResponse->ResponseList[1] ?? null;
if ($sResult) {
return $sResult;
}
$this->writeLogException(new Exceptions\LoginException);
}
$this->writeLogException(new Exceptions\LoginException);
}
/**
* TODO: passthru to parse response in JavaScript
* This will reduce CPU time on server and moves it to the client
* And can be used with the new JavaScript AbstractFetchRemote.streamPerLine(fCallback, sGetAdd)
*
* @throws \MailSo\RuntimeException
* @throws \MailSo\Net\Exceptions\*
* @throws \MailSo\Imap\Exceptions\*
*/
protected function streamResponse(?string $sEndTag = null) : void
{
try {
if (\is_resource($this->ConnectionResource())) {
\SnappyMail\HTTP\Stream::start();
$sEndTag = ($sEndTag ?: $this->getCurrentTag()) . ' ';
$sLine = \fgets($this->ConnectionResource());
do {
if (\str_starts_with($sLine, $sEndTag)) {
echo 'T '.\substr($sLine, \strlen($sEndTag));
break;
}
echo $sLine;
$sLine = \fgets($this->ConnectionResource());
} while (\strlen($sLine));
exit;
}
} catch (\Throwable $e) {
$this->writeLogException($e, \LOG_WARNING);
}
}
protected function getResponse(?string $sEndTag = null) : ResponseCollection
{
try {
$oResult = new ResponseCollection;
if (\is_resource($this->ConnectionResource())) {
$sEndTag = $sEndTag ?: $this->getCurrentTag();
while (true) {
$oResponse = $this->partialParseResponse();
$oResult->append($oResponse);
if ($oResponse->IsStatusResponse
&& Enumerations\ResponseType::UNTAGGED === $oResponse->ResponseType
&& Enumerations\ResponseStatus::PREAUTH === $oResponse->StatusOrIndex
// && (Enumerations\ResponseStatus::PREAUTH === $oResponse->StatusOrIndex || Enumerations\ResponseStatus::BYE === $oResponse->StatusOrIndex)
) {
break;
}
// RFC 5530
if ($sEndTag === $oResponse->Tag && \is_array($oResponse->OptionalResponse) && 'CLIENTBUG' === $oResponse->OptionalResponse[0]) {
// The server has detected a client bug.
// \SnappyMail\Log::warning('IMAP', "{$oResponse->OptionalResponse[0]}: {$this->lastCommand}");
}
if ($sEndTag === $oResponse->Tag || Enumerations\ResponseType::CONTINUATION === $oResponse->ResponseType) {
if (isset($this->aTagTimeouts[$sEndTag])) {
$this->writeLog((\microtime(true) - $this->aTagTimeouts[$sEndTag]).' ('.$sEndTag.')', \LOG_DEBUG);
unset($this->aTagTimeouts[$sEndTag]);
}
break;
}
}
}
$oResult->validate();
} catch (\Throwable $e) {
$this->writeLogException($e, \LOG_WARNING);
}
return $oResult;
}
// public function yieldUntaggedResponses() : \Generator
public function yieldUntaggedResponses() : iterable
{
try {
$oResult = new ResponseCollection;
if (\is_resource($this->ConnectionResource())) {
$sEndTag = $this->getCurrentTag();
while (true) {
$oResponse = $this->partialParseResponse();
if (Enumerations\ResponseType::UNTAGGED === $oResponse->ResponseType) {
yield $oResponse;
} else {
$oResult->append($oResponse);
}
// RFC 5530
if ($sEndTag === $oResponse->Tag && \is_array($oResponse->OptionalResponse) && 'CLIENTBUG' === $oResponse->OptionalResponse[0]) {
// The server has detected a client bug.
// \SnappyMail\Log::warning('IMAP', "{$oResponse->OptionalResponse[0]}: {$this->lastCommand}");
}
if ($sEndTag === $oResponse->Tag || Enumerations\ResponseType::CONTINUATION === $oResponse->ResponseType) {
if (isset($this->aTagTimeouts[$sEndTag])) {
$this->writeLog((\microtime(true) - $this->aTagTimeouts[$sEndTag]).' ('.$sEndTag.')', \LOG_DEBUG);
unset($this->aTagTimeouts[$sEndTag]);
}
break;
}
}
}
$oResult->validate();
} catch (\Throwable $e) {
$this->writeLogException($e, \LOG_WARNING);
}
}
protected function prepareParamLine(array $aParams = array()) : string
{
$sReturn = '';
foreach ($aParams as $mParamItem) {
if (\is_array($mParamItem) && \count($mParamItem)) {
$sReturn .= ' ('.\trim($this->prepareParamLine($mParamItem)).')';
} else if (\is_string($mParamItem)) {
$sReturn .= ' '.$mParamItem;
}
}
return $sReturn;
}
protected function getNewTag() : string
{
++$this->iTagCount;
return $this->getCurrentTag();
}
protected function getCurrentTag() : string
{
return $this->TAG_PREFIX.$this->iTagCount;
}
public function EscapeString(?string $sStringForEscape) : string
{
if (null === $sStringForEscape) {
return 'NIL';
}
/*
// literal-string
$this->hasCapability('LITERAL+')
if (\preg_match('/[\r\n\x00\x80-\xFF]/', $sStringForEscape)) {
return \sprintf("{%d}\r\n%s", \strlen($sStringForEscape), $sStringForEscape);
}
*/
// quoted-string
return '"' . \addcslashes($sStringForEscape, '\\"') . '"';
}
public function getLogName() : string
{
return 'IMAP';
}
/**
* RFC 2971
* Don't have to be logged in to call this command
*/
public function ServerID() : string
{
if ($this->hasCapability('ID')) {
foreach ($this->SendRequestGetResponse('ID', [null]) as $oResponse) {
if ('ID' === $oResponse->ResponseList[1] && \is_array($oResponse->ResponseList[2])) {
$c = \count($oResponse->ResponseList[2]);
$aResult = [];
for ($i = 0; $i < $c; $i += 2) {
$aResult[] = $oResponse->ResponseList[2][$i] . '=' . $oResponse->ResponseList[2][$i+1];
}
return \implode(' ', $aResult);
}
}
}
return 'UNKNOWN';
}
/**
* RFC 4978
* It is RECOMMENDED that the client uses TLS compression.
*//*
public function Compress() : bool
{
try {
if ($this->hasCapability('COMPRESS=DEFLATE')) {
$this->SendRequestGetResponse('COMPRESS', ['DEFLATE']);
\stream_filter_append($this->ConnectionResource(), 'zlib.inflate');
\stream_filter_append($this->ConnectionResource(), 'zlib.deflate', STREAM_FILTER_WRITE, array(
'level' => 6, 'window' => 15, 'memory' => 9
));
return true;
}
} catch (\Throwable $e) {
}
return false;
}*/
public function EscapeFolderName(string $sFolderName) : string
{
return $this->EscapeString($this->UTF8 ? $sFolderName : \MailSo\Base\Utils::Utf8ToUtf7Modified($sFolderName));
}
public function toUTF8(string $sText) : string
{
return $this->UTF8 ? $sText : \MailSo\Base\Utils::Utf7ModifiedToUtf8($sText);
}
}