fix(imap_client): status gaps on selected mailboxes — missing messages key and null getUid#45
Merged
Conversation
PR Summary
|
Check if fetch result is null before accessing UID.
messages key (PHP 8+) and null getUid() during UIDNEXT sync (ActiveSync)
messages key (PHP 8+) and null getUid() during UIDNEXT sync (ActiveSync)
ralflang
approved these changes
May 22, 2026
Member
|
Potentially closes horde/imp#48 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix IMAP status handling on selected mailboxes (PHP 8+ warnings and ActiveSync fatal)
Target: horde/Imap_Client →
FRAMEWORK_6_0PR: #45
Branch:
fix/undefined_array_key_messagesSummary
This PR hardens
Horde_Imap_Clientwhen mailbox status is incomplete on an already-selected mailbox:Undefined array key "messages"whensearch(),thread(), or related code callsstatus()but the socket driver omits uncached fields from the result.Call to a member function getUid() on nullinSocket::_status()whenSTATUS_UIDNEXT_FORCEinfersUIDNEXTviaFETCHbut no message is returned (common during ActiveSync / CONDSTORE sync).Both issues share the same underlying gap:
Socket::_status()did not always fall back to an IMAPSTATUScommand for missing fields on the selected mailbox. The branch adds that fallback consistently and guards callers that assumed status arrays were always complete.Problem 1: Undefined array key
messages(PHP 8+)Symptom
Application logs show many entries such as:
This often appears during normal mail usage (IMP mailbox list, search, threading) on PHP 8.0+, where reading a non-existent array key emits a warning that Horde logs as an error.
Root cause
Base::search()optimizes empty-mailbox handling by callingstatus()withSTATUS_MESSAGES(and sometimesSTATUS_HIGHESTMODSEQ), then reads$status_res['messages'](~line 2287).Socket::_status()treats a selected mailbox differently from an unselected one: ifHorde_Imap_Client_Base_Mailbox::getStatus()returnsnullfor a field (e.g.STATUS_MESSAGESnot yet set afterSELECT/EXAMINE), it only handles special cases (UIDNEXT_FORCE,FIRSTUNSEEN/UNSEEN). For ordinary fields likemessages, it neither fills$datanor adds the field to theSTATUScommand$query.status()then returns an array without amessageskey.$status_res['messages']triggersUndefined array key "messages"on PHP 8+.The bug is intermittent: it depends on whether the mailbox object already has cached status from a prior
SELECT/EXAMINEresponse.Affected code paths (Problem 1)
Base.php~2256SEARCH_RESULTS_COUNToptimization forALLBase.php~2287search()empty-mailbox optimizationBase.php~2451thread()empty-mailbox shortcutBase.php~3556resolveIds()ALLsequence optimizationProblem 2:
getUid()on null during UIDNEXT / sync (fatal)Symptom
Horde logs a fatal error (not just a PHP warning), often during ActiveSync mail sync:
Example stack trace:
Mobile clients (Outlook, iOS Mail, etc.) trigger this path when syncing folder changes.
Root cause
_syncStatus()callsstatus()withSTATUS_UIDNEXT_FORCE(among other flags) to build a sync token (getSyncToken()→_getSearchCache()).UIDNEXT,Socket::_status()uses theSTATUS_UIDNEXT_FORCEbranch: if cachedSTATUS_MESSAGESis non-zero, itFETCHes the largest message UID and setsuidnext = fetch_uid + 1.Horde_Imap_Client_Fetch_Results::first()returns a fetch object only when exactly one message is in the result; otherwise it returnsnull(0 or 2+ messages).$fetch_res->first()->getUid()unconditionally → fatal when the fetch returned no message.Why can
FETCHreturn nothing whileSTATUS_MESSAGESsays there are messages?STATUS_MESSAGESafter expunge or concurrent mailbox changes during sync.fetch()/search()and server-side updates.SELECT/EXAMINEomitted optional fields (includingUIDNEXT).This is a separate failure mode from Problem 1, but the same fix pattern applies: if the optimized local path cannot produce a value, fall back to IMAP
STATUS.Affected code paths (Problem 2)
Socket.php_status()STATUS_UIDNEXT_FORCE+FETCHlargest UIDBase.php_syncStatus()status(… | STATUS_UIDNEXT_FORCE)Base.phpgetSyncToken()/_getSearchCache()fetch()→resolveIds()→search()during message exportSolution
Socket.php—_status()(both problems)A. Missing ordinary status fields (Problem 1)
When the mailbox equals
$this->_selectedandgetStatus()returnsnullfor a field that is not handled byUIDNEXT_FORCEor unseen-flag branches, add the field to$queryso aSTATUScommand is issued (same as for unselected mailboxes):B. Failed UIDNEXT inference via FETCH (Problem 2)
In the
STATUS_UIDNEXT_FORCEbranch, guard the fetch result and fall back toSTATUSwhenfirst()is null:Fetch_Results::first()contract (for reviewers):Base.php— defensive reads (Problem 1)Use
!empty($status_res['messages'])or$res['messages'] ?? 0where the code previously assumedmessageswas always present. A missing key is treated as an empty mailbox (count 0), matching the existing$default_retbranch insearch().Files changed
lib/Horde/Imap/Client/Socket.phpSTATUSwhen selected-mailbox status is not cached; guard nullfirst()inSTATUS_UIDNEXT_FORCE/FETCHpathlib/Horde/Imap/Client/Base.phpmessagesinsearch(),thread(), count optimization, andresolveIds()Commits (branch)
0a6f7e5STATUSquery for non-cached status fields523cf06messagesinBase.php(?? 0,!empty())d906639getUid()inSocket.phpTest plan
Problem 1 (PHP warnings / IMP)
Horde_Imap_Client): browse INBOX and other folders (including empty folders).Undefined array key "messages"atBase.php:2287(or related lines).STATUS_HIGHESTMODSEQ+STATUS_MESSAGEStogether).Problem 2 (ActiveSync fatal)
getUid() on nullin logs.Call to a member function getUid() on nullinSocket.php(~1712).Compatibility
status()andsearch()return shapes are the same; missingmessagesis now either filled bySTATUSor interpreted as0; missingUIDNEXTis filled bySTATUSwhen theFETCHshortcut fails.STATUSon the selected mailbox when cache is empty or inference fails (RFC 3501 permitsSTATUSon selected mailboxes).!empty()/??on missing keys).Deployment note (horde-deployment)
These edits live under
vendor/horde/imap_client/. Acomposer updatemay overwrite them unless this PR is merged upstream in horde/Imap_Client or applied via composer-patches. Pin or patch the dependency after merge for a durable fix.Related issues
Undefined array keynoise in Horde IMAP integrations (e.g. Nextcloud Mail withbytestream/horde-imap-client) when status fields are missing orempty()is used on numeric0values such ashighestmodseq.status(… | STATUS_UIDNEXT_FORCE)are especially sensitive to staleSTATUS_MESSAGESand emptyFETCHresults; this PR closes that fatal path without disabling sync optimizations.