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

/**
 * The thinking part of showing a mailbox's message list, with none
 * of the talking-to-a-server part. Listing a folder over IMAP mixes
 * two very different jobs: deciding what to ask the server (which
 * search terms narrow the list, how to phrase a sort, how big a
 * batch of messages to request next, how to recover when one request
 * fails) and actually sending those requests and reading the
 * replies. This class holds only the first job. Every method here is
 * a plain function of its inputs that returns a value and touches no
 * socket, session, clock, or log, so the rules that make a mailbox
 * view fast and resilient can be checked by a unit test instead of
 * needing a live mail server.
 *
 * ImapMailBackend is the partner that does the talking: it calls
 * these methods to decide each step, sends the resulting IMAP
 * commands, and feeds the replies back in.
 *
 * @author Chris Pollett
 */
class ImapListing
{
    /**
     * How many messages to ask for in the first FETCH batch when
     * filling a mailbox view. Kept modest so the first request comes
     * back quickly; the batch then grows on each success (see
     * growBatch) so a healthy server is soon fetching large runs at
     * once.
     */
    const INITIAL_FETCH_BATCH = 25;
    /**
     * Builds the IMAP SEARCH terms that narrow a mailbox view to the
     * messages the person asked to see. It stacks up to three
     * independent narrowing choices: a free-text term (matched
     * against either the subject or the sender), an unread-only
     * toggle, and a flagged-only toggle. IMAP treats space-separated
     * SEARCH terms as "all of these must hold," so the pieces simply
     * sit next to each other. When nothing narrows the view it
     * returns "ALL", the term that matches every message.
     *
     * The free-text term is wrapped in quotes and its quotes and
     * backslashes are escaped (via ImapMailBackend's shared quoting)
     * so a term cannot break out of its quotes and inject a second
     * command.
     *
     * @param string $filter the free-text term, or the empty string
     *      when the person has not typed one
     * @param bool $unread_only true to keep only messages that have
     *      not been marked read
     * @param bool $flagged_only true to keep only messages the
     *      person has flagged
     * @return string an IMAP SEARCH term fragment, or "ALL" when
     *      nothing narrows the view
     */
    public static function searchCriteria($filter,
        $unread_only = false, $flagged_only = false)
    {
        $parts = [];
        if ($unread_only) {
            $parts[] = 'UNSEEN';
        }
        if ($flagged_only) {
            $parts[] = 'FLAGGED';
        }
        if ($filter !== '') {
            $quoted = '"' . ImapMailBackend::imapQuote($filter) . '"';
            $parts[] = "OR SUBJECT $quoted FROM $quoted";
        }
        if (empty($parts)) {
            return 'ALL';
        }
        return implode(' ', $parts);
    }
    /**
     * Builds the short text tag that identifies one particular
     * combination of sort choice and narrowing toggles. The caller
     * uses it as part of a cache key so that, say, the unread-only
     * subject-sorted view of a folder does not get served the cached
     * order computed for its show-everything date-sorted view.
     *
     * @param array $sort the sort choice, with a "key" (one of
     *      "date", "subject", "from") and a "reverse" flag
     * @param bool $unread_only whether the unread-only toggle is on
     * @param bool $flagged_only whether the flagged-only toggle is on
     * @return string a tag unique to this sort-and-toggle combination
     */
    public static function sortSignature($sort, $unread_only = false,
        $flagged_only = false)
    {
        return $sort['key'] .
            (!empty($sort['reverse']) ? '-rev' : '') .
            ($unread_only ? '-unread' : '') .
            ($flagged_only ? '-flagged' : '');
    }
    /**
     * Decides how to ask an IMAP server that supports the SORT
     * extension to order a mailbox view, turning the view's
     * (key, reverse) choice into a concrete plan the backend can act
     * on. There are two shapes of answer:
     *
     *  - "search": the common newest-first-by-date case needs no SORT
     *    at all. The backend runs a plain SEARCH and reverses the
     *    result, since a SEARCH already comes back oldest-first.
     *  - "sort": every other case asks the server to SORT, and the
     *    plan carries the exact SORT arguments to use.
     *
     * The tricky part this method gets right is direction. IMAP SORT
     * always counts up (oldest first for date, A to Z for subject and
     * sender), but the view's "reverse off" means newest-first for
     * date yet A-to-Z for subject and sender. The per-key table below
     * encodes when "reverse off" in the view actually means "ask the
     * server to reverse," so date and the text fields each come out
     * the way a reader expects.
     *
     * @param array $sort the sort choice, with "key" (one of "date",
     *      "subject", "from") and a "reverse" flag
     * @return array a plan: ["mode" => "search"] for the no-SORT
     *      case, or ["mode" => "sort", "args" => string] carrying the
     *      SORT arguments (for example "REVERSE DATE" or "SUBJECT")
     */
    public static function serverSortPlan($sort)
    {
        $key = $sort['key'];
        $reverse = !empty($sort['reverse']);
        $needs_sort_command = $key !== 'date' || $reverse;
        if (!$needs_sort_command) {
            return ['mode' => 'search'];
        }
        /* IMAP SORT counts up; the view's "reverse off" is newest-
           first for date but A-to-Z for subject and sender, so the
           direction the view wants is the opposite of the server's
           natural one for the text fields. This table says, per key,
           which view direction should be sent to the server as a
           REVERSE. */
        $reverse_when = ['date' => false, 'subject' => true,
            'from' => true];
        $apply_reverse = $reverse === ($reverse_when[$key] ?? true);
        $sort_token = strtoupper($key);
        $args = $apply_reverse ? "REVERSE $sort_token" : $sort_token;
        return ['mode' => 'sort', 'args' => $args];
    }
    /**
     * Orders matched messages by arrival when the server cannot sort
     * for us and the chosen key is date. A message's sequence number
     * already climbs with arrival, so the numbers themselves are a
     * faithful stand-in for date order and no extra server round-trip
     * is needed. A SEARCH hands them back oldest-first; this returns
     * them newest-first unless the view asked for oldest-first.
     *
     * @param array $ids the matched sequence numbers, oldest-first as
     *      a SEARCH returns them
     * @param bool $reverse true when the view wants oldest-first
     * @return array the same ids ordered for display
     */
    public static function fallbackDateOrder($ids, $reverse)
    {
        return $reverse ? $ids : array_reverse($ids);
    }
    /**
     * Orders matched messages by subject or sender when the server
     * cannot sort for us. The caller has already fetched the one
     * header field being sorted on for each matched message; this
     * compares those values and returns the sequence numbers in the
     * order to display them. Ties (equal or missing header values)
     * fall back to sequence-number order so the result is always
     * stable. For the sender key the comparison uses the display
     * name, not the raw address (see extractFromName).
     *
     * @param array $ids the matched sequence numbers
     * @param array $values a map from sequence number to that
     *      message's raw header value for the field being sorted on
     * @param string $key the sort field, "subject" or "from"
     * @param bool $reverse true when the view wants Z-to-A instead of
     *      A-to-Z
     * @return array the sequence numbers ordered for display
     */
    public static function fallbackFieldOrder($ids, $values, $key,
        $reverse)
    {
        $pairs = [];
        foreach ($ids as $id) {
            $value = $values[$id] ?? '';
            if ($key === 'from') {
                $value = self::extractFromName($value);
            }
            $pairs[] = [$id, mb_strtolower(trim($value))];
        }
        usort($pairs, function ($first, $second) {
            $compare = strcmp($first[1], $second[1]);
            if ($compare !== 0) {
                return $compare;
            }
            return $first[0] - $second[0];
        });
        $list = array_map(function ($pair) {
            return $pair[0];
        }, $pairs);
        if ($reverse) {
            $list = array_reverse($list);
        }
        return $list;
    }
    /**
     * Pulls a human-friendly sender name out of a raw From header.
     * Given a value like '"Ada Lovelace" <ada@example.com>' it
     * returns "Ada Lovelace"; given a bare address it returns the
     * part before the "@" as a reasonable stand-in. Surrounding
     * quotes that the mail server wraps around a name are removed.
     * Used when sorting a mailbox by sender so the order follows the
     * visible name rather than the address.
     *
     * @param string $raw the raw From header value
     * @return string the display name, or a name-like fallback drawn
     *      from the address; the empty string when nothing usable is
     *      present
     */
    public static function extractFromName($raw)
    {
        $raw = trim((string) $raw);
        if ($raw === '') {
            return '';
        }
        /* "Display Name" <addr> or Display Name <addr>: take
           everything before the angle bracket. */
        $bracket_at = strpos($raw, '<');
        if ($bracket_at !== false && $bracket_at > 0) {
            $name = trim(substr($raw, 0, $bracket_at));
            /* drop one pair of surrounding double quotes that the
               mail server wraps around names with spaces or special
               characters. */
            if (strlen($name) >= 2 && $name[0] === '"' &&
                substr($name, -1) === '"') {
                $name = substr($name, 1, -1);
            }
            if ($name !== '') {
                return $name;
            }
        }
        /* no display name: fall back to the part of the address
           before the "@", dropping any angle brackets. */
        $address = $raw;
        if ($bracket_at !== false) {
            $close = strpos($raw, '>', $bracket_at);
            if ($close !== false) {
                $address = substr($raw, $bracket_at + 1,
                    $close - $bracket_at - 1);
            }
        }
        $at = strpos($address, '@');
        if ($at !== false && $at > 0) {
            return substr($address, 0, $at);
        }
        return $address;
    }
    /**
     * Picks the size of the first FETCH batch when filling a mailbox
     * view. Small enough that the first reply is quick, and never
     * larger than the number of messages actually wanted.
     *
     * @param int $total how many messages are to be fetched in all
     * @return int the number to request in the first batch; zero when
     *      there are no messages to fetch
     */
    public static function initialBatch($total)
    {
        return $total > 0 ? min($total, self::INITIAL_FETCH_BATCH) : 0;
    }
    /**
     * Picks the size of the next FETCH batch after a run of
     * successes. The batch doubles with each success in the run, so
     * fetching speeds up quickly on a healthy server, but it is
     * capped at the number of messages still wanted so the last batch
     * never overshoots.
     *
     * @param int $remaining how many messages are still to be fetched
     * @param int $streak how many batches in a row have just
     *      succeeded
     * @return int the number to request in the next batch
     */
    public static function growBatch($remaining, $streak)
    {
        return min($remaining, 1 << $streak);
    }
    /**
     * Picks the size of the next FETCH batch after one fails. The
     * batch is halved so a smaller request has a better chance of
     * getting through a server that is struggling with the larger
     * one, but it never drops below one message.
     *
     * @param int $batch the size of the batch that just failed
     * @return int the smaller size to try next, at least one
     */
    public static function shrinkBatch($batch)
    {
        return max(1, intdiv($batch, 2));
    }
    /**
     * Puts fetched messages back into the order the view asked for.
     * Batches can come back in any order, and some sequence numbers
     * may be missing (a message that could not be fetched even one at
     * a time). This walks the requested order and keeps only the
     * messages that were actually gathered, so the result follows the
     * sort the person chose with no gaps left as holes.
     *
     * @param array $by_seq fetched messages keyed by their sequence
     *      number
     * @param array $sequence_numbers the sequence numbers in the
     *      order they should appear
     * @return array the gathered messages in display order
     */
    public static function orderBySequence($by_seq, $sequence_numbers)
    {
        $messages = [];
        foreach ($sequence_numbers as $seq) {
            if (isset($by_seq[$seq])) {
                $messages[] = $by_seq[$seq];
            }
        }
        return $messages;
    }
    /**
     * Builds the stand-in row shown for a message whose details could
     * not be fetched, even on its own, because the server has a
     * corrupt copy or keeps dropping the request. The view shows this
     * row without a clickable subject, since opening it would just hit
     * the same server problem, and keeps the rest of the mailbox
     * intact around it. The subject text to display is passed in so
     * this stays free of any language lookup.
     *
     * @param int $seq the sequence number of the message that could
     *      not be fetched
     * @param string $unreadable_subject the already-translated text
     *      to show where the subject would go
     * @return array a message row shaped like a fetched one, marked
     *      as a placeholder
     */
    public static function placeholderRow($seq, $unreadable_subject)
    {
        return [
            "SEQ" => $seq,
            "UID" => 0,
            "FLAGS" => "",
            "IS_UNREAD" => false,
            "IS_ANSWERED" => false,
            "IS_FLAGGED" => false,
            "DATE" => "",
            "SUBJECT" => $unreadable_subject,
            "FROM" => "",
            "IS_PLACEHOLDER" => true,
        ];
    }
}
X