From 5b5a258cf4ab05b289c4c0035b1b9970d888b09c Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Wed, 10 Jun 2026 00:10:44 +0200 Subject: [PATCH 1/2] feat(activesync): add EAS 16.0 Find command support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Find command for protocol version 16.0 so clients such as iOS can search mail via KQL instead of the legacy Search command. - Add Find request handler, DTOs, and QueryMapper (Find → shared IMAP search) - Add Kql parser with OR support for iPhone query patterns - Register Find WBXML code page 0x19 and advertise command for v16.0 - Extend IMAP adapter with deepTraversal, subfolder search, and UID resolution - Handle iOS virtual All Mailboxes folder IDs in ItemOperations fetch - Add unit tests for KQL and QueryMapper; update ServerTest expectations --- lib/Horde/ActiveSync.php | 2 +- lib/Horde/ActiveSync/Driver/Base.php | 17 + lib/Horde/ActiveSync/Driver/Mock.php | 20 +- lib/Horde/ActiveSync/Find/Kql.php | 113 ++++ lib/Horde/ActiveSync/Find/Params.php | 40 ++ lib/Horde/ActiveSync/Find/QueryMapper.php | 72 +++ lib/Horde/ActiveSync/Find/Results.php | 34 ++ lib/Horde/ActiveSync/Imap/Adapter.php | 183 +++++- lib/Horde/ActiveSync/Request/Find.php | 549 ++++++++++++++++++ .../ActiveSync/Request/ItemOperations.php | 77 ++- lib/Horde/ActiveSync/Wbxml.php | 30 + test/Horde/ActiveSync/FindKqlTest.php | 40 ++ test/Horde/ActiveSync/FindQueryMapperTest.php | 70 +++ test/Horde/ActiveSync/ServerTest.php | 4 +- 14 files changed, 1235 insertions(+), 16 deletions(-) create mode 100644 lib/Horde/ActiveSync/Find/Kql.php create mode 100644 lib/Horde/ActiveSync/Find/Params.php create mode 100644 lib/Horde/ActiveSync/Find/QueryMapper.php create mode 100644 lib/Horde/ActiveSync/Find/Results.php create mode 100644 lib/Horde/ActiveSync/Request/Find.php create mode 100644 test/Horde/ActiveSync/FindKqlTest.php create mode 100644 test/Horde/ActiveSync/FindQueryMapperTest.php diff --git a/lib/Horde/ActiveSync.php b/lib/Horde/ActiveSync.php index 54ddfc8c..5f03e276 100644 --- a/lib/Horde/ActiveSync.php +++ b/lib/Horde/ActiveSync.php @@ -1112,7 +1112,7 @@ public function getSupportedCommands() case self::VERSION_FOURTEEN: case self::VERSION_FOURTEENONE: case self::VERSION_SIXTEEN: - return 'Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert'; + return 'Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert,Find'; } } diff --git a/lib/Horde/ActiveSync/Driver/Base.php b/lib/Horde/ActiveSync/Driver/Base.php index 715dd8c1..289ff86d 100644 --- a/lib/Horde/ActiveSync/Driver/Base.php +++ b/lib/Horde/ActiveSync/Driver/Base.php @@ -537,6 +537,23 @@ abstract public function moveMessage($folderid, array $ids, $newfolderid); */ abstract public function getSearchResults(Horde_ActiveSync_Search_Params $params): Horde_ActiveSync_Search_Results; + /** + * Returns Find command results for the given parameters. + * + * @author Torben Dannhauer + * + * @param Horde_ActiveSync_Find_Params $params The find parameters. + * @param array $bodyprefs Body preference options. + * @param integer $mimesupport MIME support flag. + * + * @return Horde_ActiveSync_Find_Results + */ + abstract public function getFindResults( + Horde_ActiveSync_Find_Params $params, + array $bodyprefs = [], + $mimesupport = 0 + ): Horde_ActiveSync_Find_Results; + /** * Stat folder. Note that since the only thing that can ever change for a * folder is the name, we use that as the 'mod' value. diff --git a/lib/Horde/ActiveSync/Driver/Mock.php b/lib/Horde/ActiveSync/Driver/Mock.php index 8c66a419..b01431e9 100644 --- a/lib/Horde/ActiveSync/Driver/Mock.php +++ b/lib/Horde/ActiveSync/Driver/Mock.php @@ -103,7 +103,25 @@ public function moveMessage($folderid, array $ids, $newfolderid) */ public function getSearchResults(Horde_ActiveSync_Search_Params $params): Horde_ActiveSync_Search_Results { - return []; + return new Horde_ActiveSync_Search_Results(0, [], 0); + } + + /** + * Returns Find command results for the given parameters. + * + * @author Torben Dannhauer + */ + public function getFindResults( + Horde_ActiveSync_Find_Params $params, + array $bodyprefs = [], + $mimesupport = 0 + ): Horde_ActiveSync_Find_Results { + return new Horde_ActiveSync_Find_Results( + Horde_ActiveSync_Request_Find::STATUS_SUCCESS, + Horde_ActiveSync_Request_Find::STORE_STATUS_SUCCESS, + 0, + [] + ); } /** diff --git a/lib/Horde/ActiveSync/Find/Kql.php b/lib/Horde/ActiveSync/Find/Kql.php new file mode 100644 index 00000000..c8f7bb28 --- /dev/null +++ b/lib/Horde/ActiveSync/Find/Kql.php @@ -0,0 +1,113 @@ + + * @package ActiveSync + */ + +/** + * Parse a subset of Keyword Query Language used by EAS Find requests. + * + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 Horde LLC (http://www.horde.org) + * @author Torben Dannhauer + * @package ActiveSync + */ +class Horde_ActiveSync_Find_Kql +{ + /** + * Parse KQL / free-text into an IMAP search query. + * + * @param string $text Raw query string from the client. + * + * @return Horde_Imap_Client_Search_Query + */ + public static function toImapQuery(string $text): Horde_Imap_Client_Search_Query + { + $text = trim($text); + $query = new Horde_Imap_Client_Search_Query(); + $query->charset('UTF-8', false); + + if ($text === '') { + return $query; + } + + if (preg_match('/\s+OR\s+/i', $text)) { + $parts = preg_split('/\s+OR\s+/i', $text); + $queries = []; + foreach ($parts as $part) { + $part = trim($part); + if ($part !== '') { + $queries[] = self::_parseClause($part); + } + } + if (count($queries) > 1) { + $query->orSearch($queries); + + return $query; + } + if (count($queries) === 1) { + return $queries[0]; + } + } + + return self::_parseClause($text); + } + + /** + * Parse a single KQL clause (no OR). + * + * @param string $text One clause from a Find FreeText string. + * + * @return Horde_Imap_Client_Search_Query + */ + protected static function _parseClause(string $text): Horde_Imap_Client_Search_Query + { + $text = trim($text); + $query = new Horde_Imap_Client_Search_Query(); + $query->charset('UTF-8', false); + + if (preg_match('/^\s*(from|to|cc|bcc|subject)\s*:\s*("([^"]+)"|(\S+))\s*$/i', $text, $m)) { + $value = !empty($m[3]) ? $m[3] : $m[4]; + $query->headerText(Horde_String::lower($m[1]), $value, false); + + return $query; + } + + if (preg_match('/^"([^"]+)"$/', $text, $m)) { + $query->text($m[1], false); + + return $query; + } + + if (preg_match('/^hasattachment:\s*(yes|true|1)$/i', $text)) { + $query->text('multipart', false); + + return $query; + } + + if (preg_match('/^received\s*(>=|<=|>|<|:)\s*(\d{4}-\d{2}-\d{2})/i', $text, $m)) { + $date = new Horde_Date($m[2], 'UTC'); + $op = $m[1]; + if ($op === '>=' || $op === '>') { + $query->dateSearch($date, Horde_Imap_Client_Search_Query::DATE_SINCE); + } elseif ($op === '<=' || $op === '<') { + $query->dateSearch($date, Horde_Imap_Client_Search_Query::DATE_BEFORE); + } else { + $query->dateSearch($date, Horde_Imap_Client_Search_Query::DATE_ON); + } + + return $query; + } + + $query->text($text, false); + + return $query; + } +} diff --git a/lib/Horde/ActiveSync/Find/Params.php b/lib/Horde/ActiveSync/Find/Params.php new file mode 100644 index 00000000..a8495c72 --- /dev/null +++ b/lib/Horde/ActiveSync/Find/Params.php @@ -0,0 +1,40 @@ + + * @package ActiveSync + */ + +/** + * Parameters for an EAS Find command request. + * + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 Horde LLC (http://www.horde.org) + * @author Torben Dannhauer + * @package ActiveSync + */ +class Horde_ActiveSync_Find_Params +{ + /** + * @param string $type One of 'mailbox' or 'gal'. + * @param string|null $searchId Client-provided search session id. + * @param array $query Parsed query (freetext, class, collectionid). + * @param array $options Options (picture, etc.). + * @param int $start Result range start. + * @param int $limit Maximum results to return. + * @param bool $deepTraversal Search subfolders when true. + */ + public function __construct( + public string $type, + public ?string $searchId, + public array $query, + public array $options, + public int $start, + public int $limit, + public bool $deepTraversal, + ) {} +} diff --git a/lib/Horde/ActiveSync/Find/QueryMapper.php b/lib/Horde/ActiveSync/Find/QueryMapper.php new file mode 100644 index 00000000..04d375cd --- /dev/null +++ b/lib/Horde/ActiveSync/Find/QueryMapper.php @@ -0,0 +1,72 @@ + + * @package ActiveSync + */ +class Horde_ActiveSync_Find_QueryMapper +{ + /** + * Convert Find parameters into the structure expected by + * Horde_Core_ActiveSync_Driver::getSearchResults(). + * + * @param Horde_ActiveSync_Find_Params $params Parsed Find request. + * + * @return Horde_ActiveSync_Search_Params + */ + public static function toSearchParams( + Horde_ActiveSync_Find_Params $params + ): Horde_ActiveSync_Search_Params { + $type = Horde_String::lower($params->type); + + if ($type === 'gal') { + $text = $params->query['text'] + ?? $params->query['freetext'] + ?? ''; + + return new Horde_ActiveSync_Search_Params( + type: 'gal', + query: [$text], + options: $params->options, + start: $params->start, + limit: $params->limit, + rebuildResults: false, + deepTraversal: false, + ); + } + + $criteria = []; + if (!empty($params->query['class'])) { + $criteria['FolderType'] = $params->query['class']; + } + if (!empty($params->query['serverid'])) { + $criteria['serverid'] = $params->query['serverid']; + } + if (!empty($params->query['freetext'])) { + $criteria[Horde_ActiveSync_Request_Search::SEARCH_FREETEXT] + = $params->query['freetext']; + } + + return new Horde_ActiveSync_Search_Params( + type: 'mailbox', + query: [ + [ + 'op' => Horde_ActiveSync_Request_Search::SEARCH_AND, + 'value' => $criteria, + ], + ], + options: $params->options, + start: $params->start, + limit: $params->limit, + rebuildResults: false, + deepTraversal: $params->deepTraversal, + ); + } +} diff --git a/lib/Horde/ActiveSync/Find/Results.php b/lib/Horde/ActiveSync/Find/Results.php new file mode 100644 index 00000000..ad9750fb --- /dev/null +++ b/lib/Horde/ActiveSync/Find/Results.php @@ -0,0 +1,34 @@ + + * @package ActiveSync + */ + +/** + * Results for an EAS Find command request. + * + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 Horde LLC (http://www.horde.org) + * @author Torben Dannhauer + * @package ActiveSync + */ +class Horde_ActiveSync_Find_Results +{ + /** + * @param int $status Top-level Find status. + * @param int $storeStatus Store-level status. + * @param int $total Total matching entries. + * @param array|null $rows Result rows or null on error. + */ + public function __construct( + public int $status, + public int $storeStatus, + public int $total, + public ?array $rows, + ) {} +} diff --git a/lib/Horde/ActiveSync/Imap/Adapter.php b/lib/Horde/ActiveSync/Imap/Adapter.php index 62768835..97a09b82 100644 --- a/lib/Horde/ActiveSync/Imap/Adapter.php +++ b/lib/Horde/ActiveSync/Imap/Adapter.php @@ -604,7 +604,7 @@ public function ping(Horde_ActiveSync_Folder_Imap $folder) * * @param array $query The search query. * @param array $options The search options. - * @param bool $deepTraversal Not currently supported. + * @param bool $deepTraversal If true, include sub-mailboxes of the target folder. * * @return array An array of 'uniqueid', 'searchfolderid' hashes. */ @@ -613,6 +613,163 @@ public function queryMailbox(array $query, array $options, bool $deepTraversal): return $this->_doQuery($query, $options, $deepTraversal); } + /** + * Resolve a message long id (mailbox:uid) when the client uses a virtual + * folder id (e.g. iOS All Mailboxes "M<uid>" from Find search). + * + * @author Torben Dannhauer + * + * @param integer $uid IMAP message UID. + * + * @return string|null Long id or null if not found. + */ + public function resolveLongIdForUid(int $uid): ?string + { + if ($uid <= 0) { + return null; + } + + $imap_query = new Horde_Imap_Client_Search_Query(); + $imap_query->ids(new Horde_Imap_Client_Ids([$uid], false)); + + $matches = []; + foreach ($this->getMailboxes() as $mailbox) { + try { + $search_res = $this->_getImapOb()->search( + $mailbox['ob'], + $imap_query, + [ + 'results' => [Horde_Imap_Client::SEARCH_RESULTS_MATCH], + ] + ); + } catch (Horde_Imap_Client_Exception $e) { + continue; + } + + if ($search_res['count'] > 0) { + $matches[] = $mailbox['ob']->utf8; + } + } + + if (!$matches) { + return null; + } + + if (count($matches) > 1) { + $this->_logger->info(sprintf( + 'UID %d exists in %d mailboxes; using %s for ItemOperations fetch.', + $uid, + count($matches), + $matches[0] + )); + } + + return $matches[0] . ':' . $uid; + } + + /** + * Perform a Find mailbox search. + * + * @deprecated Use queryMailbox() via getSearchResults() instead. + * + * @author Torben Dannhauer + * + * @param array $query Parsed Find query (freetext, class, serverid). + * @param bool $deepTraversal If true, include subfolders. + * + * @return array Array of uniqueid/searchfolderid hashes. + */ + public function queryFind(array $query, bool $deepTraversal): array + { + $imap_query = new Horde_Imap_Client_Search_Query(); + $imap_query->charset('UTF-8', false); + + if (!empty($query['freetext'])) { + $imap_query = Horde_ActiveSync_Find_Kql::toImapQuery($query['freetext']); + } + + $mboxes = []; + if (!empty($query['serverid'])) { + $mboxes[] = new Horde_Imap_Client_Mailbox($query['serverid']); + if ($deepTraversal) { + $mboxes = array_merge($mboxes, $this->_getSubMailboxes($query['serverid'])); + } + } else { + foreach ($this->getMailboxes() as $mailbox) { + $mboxes[] = $mailbox['ob']; + } + } + + $results = []; + foreach ($mboxes as $mbox) { + try { + $search_res = $this->_getImapOb()->search( + $mbox, + $imap_query, + [ + 'results' => [ + Horde_Imap_Client::SEARCH_RESULTS_MATCH, + Horde_Imap_Client::SEARCH_RESULTS_COUNT, + ], + 'sort' => [ + Horde_Imap_Client::SORT_REVERSE, + Horde_Imap_Client::SORT_ARRIVAL, + ], + ] + ); + } catch (Horde_Imap_Client_Exception $e) { + throw new Horde_ActiveSync_Exception($e); + } + + if ($search_res['count'] == 0) { + continue; + } + + foreach ($search_res['match']->ids as $id) { + $results[] = [ + 'uniqueid' => $mbox->utf8 . ':' . $id, + 'searchfolderid' => $mbox->utf8, + ]; + } + } + + return $results; + } + + /** + * Return all sub-mailboxes for a given mailbox. + * + * @author Torben Dannhauer + * + * @param string $mailbox Parent mailbox name. + * + * @return Horde_Imap_Client_Mailbox[] + */ + protected function _getSubMailboxes($mailbox) + { + $mboxes = []; + try { + $mbox_ob = new Horde_Imap_Client_Mailbox($mailbox); + $ns = $this->_getNamespace($mailbox); + $delimiter = $ns['delimiter'] ?? '.'; + $list = $this->_getImapOb()->listMailboxes( + $mbox_ob->list_escape . $delimiter . '*', + Horde_Imap_Client::MBOX_ALL, + ['flat' => true] + ); + } catch (Horde_Imap_Client_Exception $e) { + return $mboxes; + } + + foreach ($list as $mbox) { + if ($mbox->utf8 !== $mailbox) { + $mboxes[] = $mbox; + } + } + + return $mboxes; + } + /** * Rename a mailbox * @@ -929,9 +1086,8 @@ protected function _getNamespace($path) * * @param array $query The search query. * @param array $options The search options (currently not used). - * @param bool $deepTraversal Not currently supported. + * @param bool $deepTraversal If true, include sub-mailboxes of the target folder. * - * @todo Implement $deepTraversal support. * @todo Implement $options support. * * @return array Returns array containing an array of hashes: @@ -967,6 +1123,12 @@ protected function _doQuery(array $query, array $options, bool $deepTraversal): break; case 'serverid': $mboxes[] = new Horde_Imap_Client_Mailbox($value); + if ($deepTraversal) { + $mboxes = array_merge( + $mboxes, + $this->_getSubMailboxes($value) + ); + } break; case Horde_ActiveSync_Message_Mail::POOMMAIL_DATERECEIVED: $op = $q['op'] ?? ''; @@ -980,7 +1142,9 @@ protected function _doQuery(array $query, array $options, bool $deepTraversal): $imap_query->dateSearch($value, $query_range); break; case Horde_ActiveSync_Request_Search::SEARCH_FREETEXT: - $imap_query->text($value, false); + $imap_query->andSearch([ + Horde_ActiveSync_Find_Kql::toImapQuery($value), + ]); break; case 'subquery': $imap_query->andSearch([$this->_buildSubQuery($value)]); @@ -993,6 +1157,17 @@ protected function _doQuery(array $query, array $options, bool $deepTraversal): foreach ($this->getMailboxes() as $mailbox) { $mboxes[] = $mailbox['ob']; } + } else { + $seen = []; + $unique = []; + foreach ($mboxes as $mbox) { + $key = $mbox->utf8; + if (!isset($seen[$key])) { + $seen[$key] = true; + $unique[] = $mbox; + } + } + $mboxes = $unique; } $results = []; diff --git a/lib/Horde/ActiveSync/Request/Find.php b/lib/Horde/ActiveSync/Request/Find.php new file mode 100644 index 00000000..5a745b3d --- /dev/null +++ b/lib/Horde/ActiveSync/Request/Find.php @@ -0,0 +1,549 @@ + + * @package ActiveSync + * @internal + */ +class Horde_ActiveSync_Request_Find extends Horde_ActiveSync_Request_SyncBase +{ + public const FIND_FIND = 'Find:Find'; + public const FIND_SEARCHID = 'Find:SearchId'; + public const FIND_EXECUTESEARCH = 'Find:ExecuteSearch'; + public const FIND_MAILBOXSEARCHCRITERION = 'Find:MailBoxSearchCriterion'; + public const FIND_GALSEARCHCRITERION = 'Find:GALSearchCriterion'; + public const FIND_QUERY = 'Find:Query'; + public const FIND_FREETEXT = 'Find:FreeText'; + public const FIND_OPTIONS = 'Find:Options'; + public const FIND_RANGE = 'Find:Range'; + public const FIND_DEEPTRAVERSAL = 'Find:DeepTraversal'; + public const FIND_PICTURE = 'Find:Picture'; + public const FIND_MAXSIZE = 'Find:MaxSize'; + public const FIND_MAXPICTURES = 'Find:MaxPictures'; + public const FIND_STATUS = 'Find:Status'; + public const FIND_RESPONSE = 'Find:Response'; + public const FIND_RESULT = 'Find:Result'; + public const FIND_PROPERTIES = 'Find:Properties'; + public const FIND_TOTAL = 'Find:Total'; + public const FIND_DISPLAYCC = 'Find:DisplayCc'; + public const FIND_DISPLAYBCC = 'Find:DisplayBcc'; + public const FIND_PREVIEW = 'Find:Preview'; + public const FIND_HASATTACHMENTS = 'Find:HasAttachments'; + + public const STATUS_SUCCESS = 1; + public const STATUS_ERROR = 2; + + public const STORE_STATUS_SUCCESS = 1; + public const STORE_STATUS_SERVERERR = 3; + public const STORE_STATUS_RANGEERR = 12; + + protected const MAX_RESULTS = 100; + + /** + * @var Horde_ActiveSync_Collections + */ + protected $_collections; + + /** + * Client FolderId from the Find request (for result encoding). + * + * @var string|null + */ + protected $_findFolderUid; + + /** + * Handle request. + * + * @return boolean + */ + protected function _handle() + { + $this->_logger->meta('Handling FIND command.'); + $this->_collections = $this->_activeSync->getCollectionsObject(); + + if (!$this->_decoder->getElementStartTag(self::FIND_FIND)) { + throw new Horde_ActiveSync_Exception_InvalidRequest('Missing required Find element.'); + } + + $searchId = null; + $type = null; + $query = []; + $options = []; + $range = null; + $deepTraversal = false; + $bodyprefs = []; + $mime = Horde_ActiveSync::MIME_SUPPORT_NONE; + + if ($this->_decoder->getElementStartTag(self::FIND_SEARCHID)) { + $searchId = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + if (!$this->_decoder->getElementStartTag(self::FIND_EXECUTESEARCH)) { + throw new Horde_ActiveSync_Exception_InvalidRequest('Missing required ExecuteSearch element.'); + } + + if ($this->_decoder->getElementStartTag(self::FIND_MAILBOXSEARCHCRITERION)) { + $type = 'mailbox'; + $parsed = $this->_parseMailboxCriterion(); + if ($parsed === false) { + return false; + } + $query = $parsed['query']; + if (!empty($parsed['options'])) { + $options = array_merge($options, $parsed['options']); + } + if (!empty($options['range'])) { + $range = $options['range']; + } + if (!empty($options['deeptraversal'])) { + $deepTraversal = true; + } + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + if ($this->_decoder->getElementStartTag(self::FIND_GALSEARCHCRITERION)) { + $type = 'gal'; + $query = ['text' => $this->_decoder->getElementContent()]; + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + if (!$type) { + throw new Horde_ActiveSync_Exception_InvalidRequest('Missing search criterion.'); + } + + if (!$this->_decoder->getElementEndTag()) { // ExecuteSearch + return false; + } + + if ($this->_decoder->getElementStartTag(self::FIND_OPTIONS)) { + while (1) { + if ($this->_decoder->getElementStartTag(self::FIND_RANGE)) { + $range = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + if ($this->_decoder->getElementStartTag(self::FIND_DEEPTRAVERSAL)) { + if (!($deepTraversal = $this->_decoder->getElementContent())) { + $deepTraversal = true; + } elseif (!$this->_decoder->getElementEndTag()) { + return false; + } + } + if ($this->_decoder->getElementStartTag(Horde_ActiveSync::AIRSYNCBASE_BODYPREFERENCE)) { + $this->_bodyPrefs($bodyprefs); + } + if ($this->_decoder->getElementStartTag(Horde_ActiveSync::SYNC_MIMESUPPORT)) { + $this->_mimeSupport($bodyprefs); + } + if ($this->_decoder->getElementStartTag(self::FIND_PICTURE)) { + $options[self::FIND_PICTURE] = true; + if ($this->_decoder->getElementStartTag(self::FIND_MAXSIZE)) { + $options[self::FIND_MAXSIZE] = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + if ($this->_decoder->getElementStartTag(self::FIND_MAXPICTURES)) { + $options[self::FIND_MAXPICTURES] = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + $e = $this->_decoder->peek(); + if ($e[Horde_ActiveSync_Wbxml::EN_TYPE] == Horde_ActiveSync_Wbxml::EN_TYPE_ENDTAG) { + $this->_decoder->getElementEndTag(); + break; + } + } + } + + if (!$this->_decoder->getElementEndTag()) { // Find + return false; + } + + $this->_findFolderUid = $query['collectionid'] ?? null; + $this->_logger->info(sprintf( + 'Find criteria: type=%s searchId=%s folderUid=%s serverid=%s range=%s deepTraversal=%s freetext_len=%d', + $type, + $searchId ?? '', + $this->_findFolderUid ?? '', + $query['serverid'] ?? '', + $range ?? '', + $deepTraversal ? 'yes' : 'no', + isset($query['freetext']) ? strlen($query['freetext']) : 0 + )); + if (!empty($query['freetext'])) { + $this->_logger->info('Find FreeText: ' . $query['freetext']); + } elseif (!empty($query['text'])) { + $this->_logger->info('Find GAL query: ' . $query['text']); + } + + $status = self::STATUS_SUCCESS; + $storeStatus = self::STORE_STATUS_SUCCESS; + $start = 0; + $limit = self::MAX_RESULTS; + + if ($range !== null) { + if (preg_match('/^(\d+)-(\d+)$/', $range, $matches)) { + $start = (int) $matches[1]; + $end = (int) $matches[2]; + if ($end < $start) { + $storeStatus = self::STORE_STATUS_RANGEERR; + } else { + $limit = $end - $start + 1; + // iOS sends 0-100 (101 slots); cap to server maximum. + if ($limit > self::MAX_RESULTS) { + $limit = self::MAX_RESULTS; + } + } + } else { + $storeStatus = self::STORE_STATUS_RANGEERR; + } + } + + $results = null; + if ($storeStatus === self::STORE_STATUS_SUCCESS && $type) { + $params = new Horde_ActiveSync_Find_Params( + type: $type, + searchId: $searchId, + query: $query, + options: $options, + start: $start, + limit: $limit, + deepTraversal: $deepTraversal, + ); + + $results = $this->_driver->getFindResults( + $params, + empty($bodyprefs['bodyprefs']) ? [] : $bodyprefs['bodyprefs'], + $mime + ); + + if ($results->rows === null) { + $storeStatus = self::STORE_STATUS_SERVERERR; + } + } + + $this->_encoder->startWBXML(); + $this->_encoder->startTag(self::FIND_FIND); + $this->_encoder->startTag(self::FIND_STATUS); + $this->_encoder->content($status); + $this->_encoder->endTag(); + + if ($status === self::STATUS_SUCCESS) { + $this->_encoder->startTag(self::FIND_RESPONSE); + $this->_encoder->startTag(Horde_ActiveSync_Request_ItemOperations::ITEMOPERATIONS_STORE); + $this->_encoder->content('Mailbox'); + $this->_encoder->endTag(); + + $this->_encoder->startTag(self::FIND_STATUS); + $this->_encoder->content($storeStatus); + $this->_encoder->endTag(); + + if ($storeStatus === self::STORE_STATUS_SUCCESS && $results) { + if ($results->rows) { + $bodyPrefs = empty($bodyprefs['bodyprefs']) ? [] : $bodyprefs['bodyprefs']; + if (empty($bodyPrefs['preview'])) { + $bodyPrefs['preview'] = 255; + } + foreach ($results->rows as $row) { + $this->_encodeResult($row, $type, $bodyPrefs, $mime); + } + } + + $returned = $results->rows ? count($results->rows) : 0; + $searchRange = $returned + ? $start . '-' . ($start + $returned - 1) + : $start . '-' . $start; + $this->_encoder->startTag(self::FIND_RANGE); + $this->_encoder->content($searchRange); + $this->_encoder->endTag(); + + $this->_encoder->startTag(self::FIND_TOTAL); + $this->_encoder->content($results->total); + $this->_encoder->endTag(); + } + + $this->_encoder->endTag(); // Response + } + + $this->_encoder->endTag(); // Find + + return true; + } + + /** + * Parse MailBoxSearchCriterion. + * + * @return array + */ + protected function _parseMailboxCriterion() + { + $query = []; + $options = []; + + if (!$this->_decoder->getElementStartTag(self::FIND_QUERY)) { + throw new Horde_ActiveSync_Exception_InvalidRequest('Missing required Query element.'); + } + + if ($this->_decoder->getElementStartTag(Horde_ActiveSync::SYNC_FOLDERTYPE)) { + $query['class'] = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + if ($this->_decoder->getElementStartTag(Horde_ActiveSync::SYNC_FOLDERID)) { + $folderUid = $this->_decoder->getElementContent(); + try { + $query['collectionid'] = $folderUid; + $query['serverid'] = $this->_collections->getBackendIdForFolderUid($folderUid); + } catch (Horde_ActiveSync_Exception_FolderGone $e) { + $this->_logger->err($e->getMessage()); + } + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + if ($this->_decoder->getElementStartTag(self::FIND_FREETEXT)) { + $query['freetext'] = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + + if (!$this->_decoder->getElementEndTag()) { // Query + return false; + } + + if ($this->_decoder->getElementStartTag(self::FIND_OPTIONS)) { + $options = $this->_parseOptions(); + if ($options === false) { + return false; + } + } + + return ['query' => $query, 'options' => $options]; + } + + /** + * Parse an Options block. + * + * @return array + */ + protected function _parseOptions() + { + $options = []; + while (1) { + if ($this->_decoder->getElementStartTag(self::FIND_RANGE)) { + $options['range'] = $this->_decoder->getElementContent(); + if (!$this->_decoder->getElementEndTag()) { + return false; + } + } + if ($this->_decoder->getElementStartTag(self::FIND_DEEPTRAVERSAL)) { + $options['deeptraversal'] = true; + if ($this->_decoder->getElementContent() !== false + && !$this->_decoder->getElementEndTag()) { + return false; + } + } + if ($this->_decoder->getElementStartTag(self::FIND_PICTURE)) { + $options[self::FIND_PICTURE] = true; + if ($this->_decoder->getElementStartTag(self::FIND_MAXSIZE)) { + $options[self::FIND_MAXSIZE] = $this->_decoder->getElementContent(); + $this->_decoder->getElementEndTag(); + } + if ($this->_decoder->getElementStartTag(self::FIND_MAXPICTURES)) { + $options[self::FIND_MAXPICTURES] = $this->_decoder->getElementContent(); + $this->_decoder->getElementEndTag(); + } + $this->_decoder->getElementEndTag(); + } + + $e = $this->_decoder->peek(); + if ($e[Horde_ActiveSync_Wbxml::EN_TYPE] == Horde_ActiveSync_Wbxml::EN_TYPE_ENDTAG) { + $this->_decoder->getElementEndTag(); + break; + } + } + + return $options; + } + + /** + * Encode a single Find result. + * + * @param array $row Search hit from getSearchResults(). + * @param string|null $type Search type. + * @param array $bodyprefs Body preference options. + * @param integer $mime MIME support flag. + */ + protected function _encodeResult(array $row, $type, array $bodyprefs, $mime) + { + $this->_encoder->startTag(self::FIND_RESULT); + + if ($type === 'mailbox') { + $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERTYPE); + $this->_encoder->content(Horde_ActiveSync::CLASS_EMAIL); + $this->_encoder->endTag(); + + [, $uid] = explode(':', $row['uniqueid'], 2); + $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID); + $this->_encoder->content($uid); + $this->_encoder->endTag(); + + $folderUid = $this->_findFolderUid + ?? $this->_collections->getFolderUidForBackendId($row['searchfolderid']); + if (empty($folderUid)) { + $this->_logger->err(sprintf( + 'Find: no client FolderId for mailbox %s (UID in %s).', + $row['searchfolderid'], + $row['uniqueid'] + )); + } + $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERID); + $this->_encoder->content($folderUid ?: ''); + $this->_encoder->endTag(); + + $this->_encoder->startTag(self::FIND_PROPERTIES); + $msg = $this->_driver->itemOperationsFetchMailbox( + $row['uniqueid'], + $bodyprefs, + $mime + ); + $this->_encodeFindMailProperties($msg); + $this->_encoder->endTag(); // Properties + } elseif ($type === 'gal') { + $this->_encoder->startTag(self::FIND_PROPERTIES); + foreach ($row as $tag => $value) { + if ($value === '' || $tag === 'class') { + continue; + } + $this->_encoder->startTag($tag); + $this->_encoder->content($value); + $this->_encoder->endTag(); + } + $this->_encoder->endTag(); + } + + $this->_encoder->endTag(); // Result + } + + /** + * Encode the mail properties required for a Find response. + * + * @param Horde_ActiveSync_Message_Mail $msg The message object. + */ + protected function _encodeFindMailProperties(Horde_ActiveSync_Message_Mail $msg) + { + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL_SUBJECT, + $msg->subject + ); + if ($msg->datereceived instanceof Horde_Date) { + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL_DATERECEIVED, + $msg->datereceived->setTimezone('UTC')->format('Y-m-d\TH:i:s.000\Z') + ); + } + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL_DISPLAYTO, + $msg->displayto + ); + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL_FROM, + $msg->from + ); + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL_IMPORTANCE, + (string) ($msg->importance ?? 1) + ); + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL_READ, + $msg->read ? '1' : '0' + ); + if (isset($msg->isdraft)) { + $this->_encodeFindElement( + Horde_ActiveSync_Message_Mail::POOMMAIL2_ISDRAFT, + $msg->isdraft ? '1' : '0' + ); + } + + $this->_encodeFindElement(self::FIND_PREVIEW, $this->_extractPreview($msg)); + + $hasAttachments = !empty($msg->airsyncbaseattachments); + $this->_encodeFindElement( + self::FIND_HASATTACHMENTS, + $hasAttachments ? '1' : '0' + ); + $this->_encodeFindElement(self::FIND_DISPLAYCC, $msg->cc ?? ''); + $this->_encodeFindElement(self::FIND_DISPLAYBCC, $msg->bcc ?? ''); + } + + /** + * Build a Find preview string from message body data. + * + * @param Horde_ActiveSync_Message_Mail $msg The message object. + * + * @return string + */ + protected function _extractPreview(Horde_ActiveSync_Message_Mail $msg): string + { + if (empty($msg->airsyncbasebody)) { + return ''; + } + + if (!empty($msg->airsyncbasebody->preview)) { + return (string) $msg->airsyncbasebody->preview; + } + + if (empty($msg->airsyncbasebody->data)) { + return ''; + } + + $data = $msg->airsyncbasebody->data; + if (is_resource($data)) { + $data = stream_get_contents($data, 255); + if ($data === false) { + return ''; + } + } + + return Horde_String::substr((string) $data, 0, 255); + } + + /** + * @param string $tag WBXML tag name. + * @param string $value Element content. + */ + protected function _encodeFindElement($tag, $value) + { + if ($value === '' || $value === null) { + return; + } + $this->_encoder->startTag($tag); + $this->_encoder->content($value); + $this->_encoder->endTag(); + } +} diff --git a/lib/Horde/ActiveSync/Request/ItemOperations.php b/lib/Horde/ActiveSync/Request/ItemOperations.php index 850d849e..fc52f67b 100644 --- a/lib/Horde/ActiveSync/Request/ItemOperations.php +++ b/lib/Horde/ActiveSync/Request/ItemOperations.php @@ -246,14 +246,36 @@ protected function _handle() $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERTYPE); $this->_encoder->content('Email'); $this->_encoder->endTag(); - $mailbox = $collections->getBackendIdForFolderUid($value['folderid']); - $msg = $this->_driver->fetch( - $mailbox, - $value['serverentryid'], - [ - 'bodyprefs' => $value['bodyprefs'], - 'mimesupport' => $mimesupport] - ); + + $longid = $this->_resolveFetchLongId($value); + if ($longid) { + $msg = $this->_driver->itemOperationsFetchMailbox( + $longid, + $value['bodyprefs'], + $mimesupport + ); + } else { + try { + $mailbox = $collections->getBackendIdForFolderUid($value['folderid']); + } catch (Horde_ActiveSync_Exception_FolderGone $e) { + $this->_logger->err(sprintf( + 'ItemOperations fetch: folder %s not in cache for UID %s.', + $value['folderid'], + $value['serverentryid'] + )); + $this->_statusCode = self::STATUS_SERVERERR; + $mailbox = null; + } + if ($this->_statusCode == self::STATUS_SUCCESS) { + $msg = $this->_driver->fetch( + $mailbox, + $value['serverentryid'], + [ + 'bodyprefs' => $value['bodyprefs'], + 'mimesupport' => $mimesupport] + ); + } + } } } if ($this->_statusCode == self::STATUS_SUCCESS) { @@ -352,6 +374,45 @@ protected function _outputStatus() * * @return integer The size of the data. */ + /** + * Resolve mailbox:uid for ItemOperations when the client uses a virtual + * folder id from unified Find search (e.g. iOS All Mailboxes "M<uid>"). + * + * @author Torben Dannhauer + * + * @param array $value Parsed ItemOperations fetch request. + * + * @return string|null Long id suitable for itemOperationsFetchMailbox(). + */ + protected function _resolveFetchLongId(array $value): ?string + { + if (!isset($value['serverentryid']) + || !method_exists($this->_driver, 'resolveLongIdForUid')) { + return null; + } + + $folderid = $value['folderid'] ?? ''; + $needsResolve = ($folderid !== '' && $folderid[0] === 'M'); + + if (!$needsResolve) { + $collections = $this->_activeSync->getCollectionsObject(); + try { + $collections->getBackendIdForFolderUid($folderid); + return null; + } catch (Horde_ActiveSync_Exception_FolderGone $e) { + $needsResolve = true; + } + } + + if (!$needsResolve) { + return null; + } + + $uid = (int) $value['serverentryid']; + + return $uid > 0 ? $this->_driver->resolveLongIdForUid($uid) : null; + } + protected function _getDataSize($data) { if (is_resource($data)) { diff --git a/lib/Horde/ActiveSync/Wbxml.php b/lib/Horde/ActiveSync/Wbxml.php index 0dd54cf7..7585b764 100644 --- a/lib/Horde/ActiveSync/Wbxml.php +++ b/lib/Horde/ActiveSync/Wbxml.php @@ -812,6 +812,34 @@ class Horde_ActiveSync_Wbxml 0x18 => 'RemoveRightsManagementDistribution', ], + /* Find (16.0) — token order per MS-ASWBXML code page 25 / Z-Push + * @author Torben Dannhauer + */ + 0x19 => [ + 0x05 => 'Find', + 0x06 => 'SearchId', + 0x07 => 'ExecuteSearch', + 0x08 => 'MailBoxSearchCriterion', + 0x09 => 'Query', + 0x0A => 'Status', + 0x0B => 'FreeText', + 0x0C => 'Options', + 0x0D => 'Range', + 0x0E => 'DeepTraversal', + 0x11 => 'Response', + 0x12 => 'Result', + 0x13 => 'Properties', + 0x14 => 'Preview', + 0x15 => 'HasAttachments', + 0x16 => 'Total', + 0x17 => 'DisplayCc', + 0x18 => 'DisplayBcc', + 0x19 => 'GALSearchCriterion', + 0x20 => 'MaxPictures', + 0x21 => 'MaxSize', + 0x22 => 'Picture', + ], + // Windows Live 0xFE => [ 0x05 => 'Annotations', @@ -847,6 +875,8 @@ class Horde_ActiveSync_Wbxml 0x16 => 'POOMMAIL2', 0x17 => 'Notes', 0x18 => 'RightsManagement', + // EAS 16.0 + 0x19 => 'Find', // Hotmail/Outlook.com WBXML extension. 0xFE => 'WindowsLive', ], diff --git a/test/Horde/ActiveSync/FindKqlTest.php b/test/Horde/ActiveSync/FindKqlTest.php new file mode 100644 index 00000000..c4e1f9c6 --- /dev/null +++ b/test/Horde/ActiveSync/FindKqlTest.php @@ -0,0 +1,40 @@ + + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 Horde LLC (http://www.horde.org) + * @package ActiveSync + */ + +use PHPUnit\Framework\TestCase; + +class Horde_ActiveSync_FindKqlTest extends TestCase +{ + public function testPlainTextQuery() + { + $q = Horde_ActiveSync_Find_Kql::toImapQuery('meeting notes'); + $this->assertInstanceOf('Horde_Imap_Client_Search_Query', $q); + } + + public function testFromToken() + { + $q = Horde_ActiveSync_Find_Kql::toImapQuery('from:john@example.com'); + $this->assertInstanceOf('Horde_Imap_Client_Search_Query', $q); + } + + public function testSubjectToken() + { + $q = Horde_ActiveSync_Find_Kql::toImapQuery('subject:"quarterly report"'); + $this->assertInstanceOf('Horde_Imap_Client_Search_Query', $q); + } + + public function testIphoneOrExpansionQuery() + { + $kql = 'to:"Stoewer" OR cc:"Stoewer" OR from:"Stoewer" OR subject:"Stoewer" OR "Stoewer" OR "Stoewer"'; + $q = Horde_ActiveSync_Find_Kql::toImapQuery($kql); + $this->assertInstanceOf('Horde_Imap_Client_Search_Query', $q); + } +} diff --git a/test/Horde/ActiveSync/FindQueryMapperTest.php b/test/Horde/ActiveSync/FindQueryMapperTest.php new file mode 100644 index 00000000..7d59e1c3 --- /dev/null +++ b/test/Horde/ActiveSync/FindQueryMapperTest.php @@ -0,0 +1,70 @@ + + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 Horde LLC (http://www.horde.org) + * @package ActiveSync + */ + +use PHPUnit\Framework\TestCase; + +class Horde_ActiveSync_FindQueryMapperTest extends TestCase +{ + public function testMailboxQueryMapsToSearchAndCriterion() + { + $find = new Horde_ActiveSync_Find_Params( + type: 'mailbox', + searchId: '00000000-0000-0000-0000-000000000001', + query: [ + 'class' => 'Email', + 'collectionid' => 'F0cfc112a', + 'serverid' => 'INBOX', + 'freetext' => 'from:alice@example.com subject:report', + ], + options: [], + start: 0, + limit: 100, + deepTraversal: true, + ); + + $search = Horde_ActiveSync_Find_QueryMapper::toSearchParams($find); + + $this->assertSame('mailbox', $search->type); + $this->assertTrue($search->deepTraversal); + $this->assertSame(0, $search->start); + $this->assertSame(100, $search->limit); + $this->assertCount(1, $search->query); + $this->assertSame( + Horde_ActiveSync_Request_Search::SEARCH_AND, + $search->query[0]['op'] + ); + $this->assertSame('Email', $search->query[0]['value']['FolderType']); + $this->assertSame('INBOX', $search->query[0]['value']['serverid']); + $this->assertSame( + 'from:alice@example.com subject:report', + $search->query[0]['value'][Horde_ActiveSync_Request_Search::SEARCH_FREETEXT] + ); + } + + public function testGalQueryMapsToSearchGalFormat() + { + $find = new Horde_ActiveSync_Find_Params( + type: 'gal', + searchId: '00000000-0000-0000-0000-000000000002', + query: ['text' => 'Michael'], + options: [], + start: 0, + limit: 50, + deepTraversal: false, + ); + + $search = Horde_ActiveSync_Find_QueryMapper::toSearchParams($find); + + $this->assertSame('gal', $search->type); + $this->assertSame(['Michael'], $search->query); + $this->assertFalse($search->deepTraversal); + } +} diff --git a/test/Horde/ActiveSync/ServerTest.php b/test/Horde/ActiveSync/ServerTest.php index 9f2c2c7b..062084ad 100644 --- a/test/Horde/ActiveSync/ServerTest.php +++ b/test/Horde/ActiveSync/ServerTest.php @@ -9,8 +9,8 @@ */ namespace Horde\ActiveSync; - use PHPUnit\Framework\Attributes\CoversNothing; + use Horde_Test_Case as TestCase; use Horde\ActiveSync\Factory\TestServer; use Horde_ActiveSync; @@ -36,7 +36,7 @@ public function testSupportedVersions() public function testSupportedCommands() { $factory = new TestServer(); - $this->assertEquals('Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert', $factory->server->getSupportedCommands()); + $this->assertEquals('Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert,Find', $factory->server->getSupportedCommands()); $factory->server->setSupportedVersion(Horde_ActiveSync::VERSION_TWOFIVE); $this->assertEquals('Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,ResolveRecipients,ValidateCert,Provision,Search,Ping', $factory->server->getSupportedCommands()); } From f5af31f3b91c447e2c38a410409f04aa3dca1226 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Wed, 10 Jun 2026 00:30:22 +0200 Subject: [PATCH 2/2] perf(activesync): abort Find when client disconnects Check connection_aborted() during IMAP mailbox search and result encoding so cancelled Find requests stop scanning folders and fetching message previews instead of running to completion. --- lib/Horde/ActiveSync/Imap/Adapter.php | 26 ++++++++++++++++++++++++++ lib/Horde/ActiveSync/Request/Base.php | 10 ++++++++++ lib/Horde/ActiveSync/Request/Find.php | 17 +++++++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/lib/Horde/ActiveSync/Imap/Adapter.php b/lib/Horde/ActiveSync/Imap/Adapter.php index 97a09b82..8a6614e8 100644 --- a/lib/Horde/ActiveSync/Imap/Adapter.php +++ b/lib/Horde/ActiveSync/Imap/Adapter.php @@ -702,6 +702,13 @@ public function queryFind(array $query, bool $deepTraversal): array $results = []; foreach ($mboxes as $mbox) { + if ($this->_clientDisconnected()) { + $this->_logger->meta( + 'FIND/Search: Client disconnected during mailbox search.' + ); + break; + } + try { $search_res = $this->_getImapOb()->search( $mbox, @@ -1172,6 +1179,13 @@ protected function _doQuery(array $query, array $options, bool $deepTraversal): $results = []; foreach ($mboxes as $mbox) { + if ($this->_clientDisconnected()) { + $this->_logger->meta( + 'FIND/Search: Client disconnected during mailbox search.' + ); + break; + } + try { $search_res = $this->_getImapOb()->search( $mbox, @@ -1306,4 +1320,16 @@ protected function _getMsgFlags() return []; } + + /** + * Check whether the HTTP client has closed the connection. + * + * @author Torben Dannhauer + * + * @return boolean + */ + protected function _clientDisconnected(): bool + { + return function_exists('connection_aborted') && connection_aborted(); + } } diff --git a/lib/Horde/ActiveSync/Request/Base.php b/lib/Horde/ActiveSync/Request/Base.php index 75b79ce7..8f00d20a 100644 --- a/lib/Horde/ActiveSync/Request/Base.php +++ b/lib/Horde/ActiveSync/Request/Base.php @@ -297,6 +297,16 @@ protected function _requireProvisionWbxml($requestType, $status) $this->_encoder->endTag(); } + /** + * Check whether the HTTP client has closed the connection. + * + * @return boolean + */ + protected function _clientDisconnected(): bool + { + return function_exists('connection_aborted') && connection_aborted(); + } + /** * Implementation method for handling request. * diff --git a/lib/Horde/ActiveSync/Request/Find.php b/lib/Horde/ActiveSync/Request/Find.php index 5a745b3d..cb36799d 100644 --- a/lib/Horde/ActiveSync/Request/Find.php +++ b/lib/Horde/ActiveSync/Request/Find.php @@ -243,6 +243,13 @@ protected function _handle() } } + if ($this->_clientDisconnected()) { + $this->_logger->meta( + 'FIND: Client disconnected, skipping response encoding.' + ); + return true; + } + $this->_encoder->startWBXML(); $this->_encoder->startTag(self::FIND_FIND); $this->_encoder->startTag(self::FIND_STATUS); @@ -260,17 +267,23 @@ protected function _handle() $this->_encoder->endTag(); if ($storeStatus === self::STORE_STATUS_SUCCESS && $results) { + $returned = 0; if ($results->rows) { $bodyPrefs = empty($bodyprefs['bodyprefs']) ? [] : $bodyprefs['bodyprefs']; if (empty($bodyPrefs['preview'])) { $bodyPrefs['preview'] = 255; } foreach ($results->rows as $row) { + if ($this->_clientDisconnected()) { + $this->_logger->meta( + 'FIND: Client disconnected during result encoding.' + ); + break; + } $this->_encodeResult($row, $type, $bodyPrefs, $mime); + $returned++; } } - - $returned = $results->rows ? count($results->rows) : 0; $searchRange = $returned ? $start . '-' . ($start + $returned - 1) : $start . '-' . $start;