/ src / library / mail / ImapFolderListParser.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;

/**
 * Parses the untagged LIST responses defined in RFC 3501
 * section 7.2.2 into the folder entries the Mail activity shows in
 * its account pane.
 *
 * Each untagged line has the form
 *   * LIST (name-attributes) "hierarchy-delimiter" name
 * where the attribute list is parenthesized and space-separated,
 * the delimiter is a quoted character (or NIL), and the name is a
 * quoted string or a {N} literal whose payload ImapClient::send()
 * stitches inline.
 *
 * Three facts about each folder are surfaced: the folder name,
 * whether the folder is selectable, and its RFC 6154 special-use
 * role if any. A folder carrying the \Noselect attribute exists
 * only as a point in the hierarchy and cannot be opened, so the
 * caller renders it as a non-link. The special-use role drives
 * sortBySpecialUse(), which puts the folder list into a stable
 * display order since servers may return LIST results in any
 * order.
 *
 * Like ImapEnvelopeParser this stays a small tokenizer rather than
 * a single regular expression, because the name can be quoted or a
 * literal and the attribute list varies between servers.
 *
 * @author Chris Pollett
 */
class ImapFolderListParser
{
    /**
     * Parses every untagged LIST line in a server response into a
     * list of folder entries. Lines that are not LIST responses are
     * skipped.
     *
     * @param array $untagged the untagged response lines from
     *      ImapClient::send()
     * @return array list of associative arrays, each with a NAME
     *      string and a SELECTABLE boolean
     */
    public static function parse($untagged)
    {
        $folders = [];
        foreach ($untagged as $line) {
            $folder = self::parseLine($line);
            if ($folder !== null) {
                $folders[] = $folder;
            }
        }
        return $folders;
    }

    /**
     * Returns the folder list in a stable display order: INBOX
     * first, then the special-use folders in a conventional
     * sequence (Drafts, Sent, Archive, Junk, Trash, then the
     * virtual All and Flagged), then every remaining folder
     * alphabetically. Folders that share a rank - including a
     * server that marks more than one mailbox with the same
     * special-use attribute - are ordered alphabetically among
     * themselves.
     *
     * IMAP servers may return LIST results in any order (often the
     * underlying directory order), so a list is sorted here rather
     * than trusted as received. The result is a sensible default;
     * a future per-user custom folder order would override it.
     *
     * @param array $folders folder entries as produced by parse()
     * @return array the same entries in display order
     */
    public static function sortBySpecialUse($folders)
    {
        $ranked = $folders;
        usort($ranked, function ($first, $second) {
            $first_rank = self::folderRank($first);
            $second_rank = self::folderRank($second);
            if ($first_rank !== $second_rank) {
                return $first_rank <=> $second_rank;
            }
            return strcasecmp($first["NAME"], $second["NAME"]);
        });
        return $ranked;
    }

    /**
     * Gives a folder its sort rank: 0 for INBOX, then a fixed
     * sequence for the special-use roles, and a single larger rank
     * for every ordinary folder so they fall together and are then
     * ordered alphabetically by the caller.
     *
     * @param array $folder one folder entry with NAME and
     *      SPECIAL_USE keys
     * @return int the folder's sort rank
     */
    private static function folderRank($folder)
    {
        if (strcasecmp($folder["NAME"], "INBOX") === 0) {
            return 0;
        }
        $special_use_order = ["\\Drafts" => 1, "\\Sent" => 2,
            "\\Archive" => 3, "\\Junk" => 4, "\\Trash" => 5,
            "\\All" => 6, "\\Flagged" => 7];
        $special_use = $folder["SPECIAL_USE"] ?? "";
        foreach ($special_use_order as $attribute => $rank) {
            if (strcasecmp($special_use, $attribute) === 0) {
                return $rank;
            }
        }
        return 8;
    }

    /**
     * Parses a single untagged response line. Returns null when the
     * line is not a LIST response or carries no usable folder name.
     *
     * @param string $line one untagged response line
     * @return array|null an associative array with NAME and
     *      SELECTABLE, or null when the line is not a usable LIST
     *      response
     */
    private static function parseLine($line)
    {
        $marker = strpos($line, "LIST (");
        if ($marker === false) {
            return null;
        }
        $pos = $marker + strlen("LIST ");
        $attributes = self::parseFlagList($line, $pos);
        if ($attributes === null) {
            return null;
        }
        /* the hierarchy delimiter token is a quoted character
           or the bare atom NIL; preserved as DELIMITER so the
           tree-view renderer can split nested folder names on
           it. NIL flattens to an empty string. */
        self::skipSpaces($line, $pos);
        $delimiter = self::readToken($line, $pos);
        self::skipSpaces($line, $pos);
        $name = self::readToken($line, $pos);
        if ($name === "") {
            return null;
        }
        $selectable = true;
        $special_use = "";
        foreach ($attributes as $attribute) {
            if (strcasecmp($attribute, "\\Noselect") === 0) {
                $selectable = false;
            } else if (self::isSpecialUseAttribute($attribute)) {
                $special_use = $attribute;
            }
        }
        return ["NAME" => $name, "SELECTABLE" => $selectable,
            "SPECIAL_USE" => $special_use,
            "DELIMITER" => $delimiter];
    }

    /**
     * Reports whether an attribute is one of the RFC 6154
     * special-use attributes (\All, \Archive, \Drafts, \Flagged,
     * \Junk, \Sent, \Trash) that mark a mailbox's role.
     *
     * @param string $attribute one name-attribute from a LIST line
     * @return bool true when the attribute is a special-use marker
     */
    private static function isSpecialUseAttribute($attribute)
    {
        $special_use_attributes = ["\\All", "\\Archive", "\\Drafts",
            "\\Flagged", "\\Junk", "\\Sent", "\\Trash"];
        foreach ($special_use_attributes as $known) {
            if (strcasecmp($attribute, $known) === 0) {
                return true;
            }
        }
        return false;
    }

    /**
     * Parses the parenthesized, space-separated name-attribute list
     * beginning at $line[$pos], which must be "(". Advances $pos
     * past the closing ")". Returns null when the text does not
     * start a well-formed list.
     *
     * @param string $line the full line being parsed
     * @param int &$pos cursor into $line; advanced past the list
     * @return array|null the attribute strings, or null on
     *      malformed input
     */
    private static function parseFlagList($line, &$pos)
    {
        $length = strlen($line);
        if ($pos >= $length || $line[$pos] !== "(") {
            return null;
        }
        $pos++;
        $attributes = [];
        $current = "";
        while ($pos < $length) {
            $char = $line[$pos];
            if ($char === ")") {
                $pos++;
                if ($current !== "") {
                    $attributes[] = $current;
                }
                return $attributes;
            }
            if ($char === " ") {
                if ($current !== "") {
                    $attributes[] = $current;
                    $current = "";
                }
                $pos++;
                continue;
            }
            $current .= $char;
            $pos++;
        }
        return null;
    }

    /**
     * Advances $pos past any run of spaces in $line.
     *
     * @param string $line the full line being parsed
     * @param int &$pos cursor into $line; advanced past the spaces
     */
    private static function skipSpaces($line, &$pos)
    {
        $length = strlen($line);
        while ($pos < $length && $line[$pos] === " ") {
            $pos++;
        }
    }

    /**
     * Reads one token beginning at $line[$pos]: a double-quoted
     * string, a {N} literal, or a bare atom. Advances $pos past the
     * token. The bare atom NIL is normalized to the empty string.
     *
     * @param string $line the full line being parsed
     * @param int &$pos cursor into $line; advanced past the token
     * @return string the token's string value
     */
    private static function readToken($line, &$pos)
    {
        $length = strlen($line);
        if ($pos >= $length) {
            return "";
        }
        if ($line[$pos] === '"') {
            return self::readQuoted($line, $pos);
        }
        if ($line[$pos] === "{") {
            return self::readLiteral($line, $pos);
        }
        $token = "";
        while ($pos < $length && $line[$pos] !== " " &&
            $line[$pos] !== "(" && $line[$pos] !== ")") {
            $token .= $line[$pos];
            $pos++;
        }
        if ($token === "NIL") {
            return "";
        }
        return $token;
    }

    /**
     * Reads a double-quoted string beginning at $line[$pos], which
     * must be the opening quote. A backslash escapes the following
     * character. Advances $pos past the closing quote.
     *
     * @param string $line the full line being parsed
     * @param int &$pos cursor into $line; advanced past the string
     * @return string the unescaped string contents
     */
    private static function readQuoted($line, &$pos)
    {
        $length = strlen($line);
        $pos++;
        $value = "";
        while ($pos < $length) {
            $char = $line[$pos];
            if ($char === "\\" && $pos + 1 < $length) {
                $value .= $line[$pos + 1];
                $pos += 2;
                continue;
            }
            if ($char === '"') {
                $pos++;
                return $value;
            }
            $value .= $char;
            $pos++;
        }
        return $value;
    }

    /**
     * Reads a {N} literal beginning at $line[$pos], which must be
     * the opening brace. ImapClient::send() stitches a literal's
     * payload inline immediately after the brace marker, so the N
     * bytes following the "}" are the value. Advances $pos past the
     * payload.
     *
     * @param string $line the full line being parsed
     * @param int &$pos cursor into $line; advanced past the literal
     * @return string the literal payload, or the empty string when
     *      the marker is malformed
     */
    private static function readLiteral($line, &$pos)
    {
        $length = strlen($line);
        $close = strpos($line, "}", $pos);
        if ($close === false) {
            $pos = $length;
            return "";
        }
        $count = (int) substr($line, $pos + 1, $close - $pos - 1);
        $payload_start = $close + 1;
        $value = substr($line, $payload_start, $count);
        $pos = $payload_start + $count;
        return $value;
    }
}
X