<?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;
}
}