/ src / library / mail / ImapResponseParser.php
<?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;

/**
 * Small collection of static parsers for the untagged responses
 * the Mail activity reads beyond the per-message FETCH that
 * ImapEnvelopeParser handles. Two responses live here today:
 *
 *   - CAPABILITY (RFC 3501 section 7.2.1), used to detect whether
 *     the server advertises the SORT extension before the inbox
 *     view offers Subject and From column sorts.
 *
 *   - SORT (RFC 5256 section 3), the sorted sequence-number list
 *     the server returns for a SORT command. The Mail activity
 *     caches this list to paginate sorted views without re-issuing
 *     SORT on every infinite-scroll batch.
 *
 * Both methods are static and pure: they take the raw untagged
 * lines an ImapClient::send() response carries and return their
 * parse result, so the same parsers can be exercised by unit tests
 * without an IMAP connection.
 *
 * @author Chris Pollett
 */
class ImapResponseParser
{
    /**
     * Reports whether the named capability token appears in any
     * "* CAPABILITY ..." line of an untagged response. Capability
     * tokens are space-separated after the CAPABILITY keyword and
     * are matched case-insensitively as RFC 3501 specifies.
     *
     * @param array $untagged untagged lines from an ImapClient
     *      response
     * @param string $capability the capability token to look for,
     *      e.g. "SORT"
     * @return bool true if the capability is advertised
     */
    public static function hasCapability($untagged, $capability)
    {
        $needle = strtoupper($capability);
        foreach ($untagged as $line) {
            if (!preg_match('/^\* CAPABILITY (.*)$/i', $line, $m)) {
                continue;
            }
            foreach (preg_split('/\s+/', trim($m[1])) as $token) {
                if (strcasecmp($token, $needle) === 0) {
                    return true;
                }
            }
        }
        return false;
    }
    /**
     * Parses a SORT response into the ordered list of sequence
     * numbers. The line is shaped "* SORT 12 4 89 7" with the
     * numbers listed in the requested sort order; an empty server-
     * side result yields an empty array. Only the first SORT line
     * is read.
     *
     * @param array $untagged untagged lines from an ImapClient
     *      response to a SORT command
     * @return array sequence numbers in sort order, as ints
     */
    public static function parseSort($untagged)
    {
        return self::parseNumberList($untagged, 'SORT');
    }
    /**
     * Parses a SEARCH response into the list of matching sequence
     * numbers. The line is shaped "* SEARCH 12 4 89 7"; the server
     * returns matches in whatever order it pleases (commonly
     * ascending sequence order, but the spec does not require it).
     * An empty server-side result yields an empty array. Only the
     * first SEARCH line is read.
     *
     * @param array $untagged untagged lines from an ImapClient
     *      response to a SEARCH command
     * @return array matching sequence numbers, as ints
     */
    public static function parseSearch($untagged)
    {
        return self::parseNumberList($untagged, 'SEARCH');
    }
    /**
     * Internal helper that pulls the trailing sequence-number list
     * out of a "* KEYWORD n n n..." untagged line. SORT and SEARCH
     * share this shape; the difference is just the keyword. Tokens
     * that are not digit strings are skipped silently so any extra
     * server data after the number list (no known IMAP server adds
     * any, but defensive) does not corrupt the result.
     *
     * @param array $untagged untagged lines from an ImapClient
     *      response
     * @param string $keyword the untagged keyword to look for,
     *      e.g. "SORT" or "SEARCH"
     * @return array sequence numbers, as ints
     */
    private static function parseNumberList($untagged, $keyword)
    {
        $pattern = '/^\* ' . preg_quote($keyword, '/') .
            '\b(.*)$/i';
        foreach ($untagged as $line) {
            if (!preg_match($pattern, $line, $m)) {
                continue;
            }
            $list = [];
            foreach (preg_split('/\s+/', trim($m[1])) as $token) {
                if ($token !== "" && ctype_digit($token)) {
                    $list[] = (int) $token;
                }
            }
            return $list;
        }
        return [];
    }
}
X