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

use seekquarry\yioop\configs as C;
use seekquarry\yioop\models\MailAccountModel;

/**
 * MailBackend implementation that talks IMAP4rev1 to a remote
 * server. Used for accounts the user has configured under Mail
 * Settings -> External Mail Accounts.
 *
 * Connection lifecycle: the IMAP socket is opened lazily on the
 * first method call that needs the server, and reused across
 * subsequent calls. Callers should put close() in a finally block
 * so the socket is released even if a method throws. The cached
 * client tracks the currently-SELECT'd folder so adjacent calls
 * on the same folder skip the SELECT round-trip.
 *
 * IMAP-protocol primitives this backend needs (OpenImap, LOGIN,
 * LIST, FETCH BODY.PEEK, UID STORE flag, COPY/EXPUNGE for moves,
 * etc.) live here as private methods rather than being delegated
 * back into SocialComponent. SocialComponent still has its own
 * copies of these helpers during the in-progress backend
 * migration; once every handler has been ported to use this
 * backend, the SocialComponent copies will be deleted.
 *
 * Read state semantics: fetchMessage uses BODY.PEEK so the
 * implicit \Seen-on-FETCH behaviour of plain RFC822/BODY[] does
 * not fire. Callers explicitly call setFlag($folder, $uid,
 * '\\Seen', true) when they want to mark-as-read; this gives
 * IMAP and MailSite identical semantics from the caller's point
 * of view.
 *
 * @author Chris Pollett
 */
class ImapMailBackend extends MailBackend
{
    /**
     * @var array account row as returned by
     *      MailAccountModel::getAccountWithPassword, with HOST,
     *      PORT, TLS_MODE, USERNAME, PASSWORD, DISPLAY_NAME, etc.
     */
    protected $account;
    /**
     * @var object|null cached IMAP client, opened on first use,
     *      released on close(). Null when no connection is open.
     */
    protected $client;
    /**
     * @var string|null name of the folder currently SELECT'd on
     *      $client, used to avoid redundant SELECT round-trips
     *      when adjacent calls target the same folder. Null when
     *      no SELECT has been issued yet.
     */
    protected $selected_folder;
    /**
     * @var object component for userMailLog access; the backend
     *      writes the same imap-* log lines the old inline code
     *      did so MAIL_LOG_ENABLED output looks identical
     *      regardless of which code path made the IMAP calls
     */
    protected $component;
    /**
     * @var bool|null whether the server advertises the SORT
     *      extension, learned from one CAPABILITY check and kept for
     *      the life of this backend so a page of listing does not
     *      ask twice. Null until first checked.
     */
    protected $sort_supported;
    /**
     * @param array $account account row with credentials
     * @param object $component the SocialComponent instance for
     *      userMailLog access; the backend writes the same
     *      imap-* instrumentation lines the old inline code did
     */
    public function __construct($account, $component)
    {
        $this->account = $account;
        $this->client = null;
        $this->selected_folder = null;
        $this->component = $component;
        $this->sort_supported = null;
    }
    /**
     * {@inheritdoc}
     */
    public function accountId()
    {
        return (int) $this->account['ID'];
    }
    /**
     * {@inheritdoc}
     */
    public function displayName()
    {
        return $this->account['DISPLAY_NAME'] ?? '';
    }
    /**
     * {@inheritdoc}
     */
    public function senderEmail()
    {
        return MailAccountModel::senderEmail($this->account);
    }
    /**
     * {@inheritdoc}
     */
    public function isSyntheticAccount()
    {
        return false;
    }
    /**
     * Returns the folder this account opens to by default, the one
     * the user configured, or the standard inbox when none is set.
     *
     * @return string the folder name to open by default
     */
    public function defaultFolder()
    {
        return ($this->account["DEFAULT_FOLDER"] ?? "") ?: "INBOX";
    }
    /**
     * {@inheritdoc}
     */
    public function listFolders()
    {
        try {
            $client = $this->client();
            $list = $this->imapListAll($client);
            if ($list['status'] !== 'OK') {
                throw new \Exception("LIST failed: " .
                    ($list['detail'] ?? ''));
            }
            return ImapFolderListParser::sortBySpecialUse(
                ImapFolderListParser::parse($list['untagged']));
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * How long a folder's unread count is trusted before the server
     * is asked again, in seconds. Short, because unread counts change
     * as mail arrives and is read, but long enough that paging around
     * the inbox does not re-probe every folder on each page.
     */
    const MAIL_FOLDER_UNREAD_TTL = 60;
    /**
     * Fills in each folder's unread-message count for the sidebar.
     * Recently probed folders are read from a short-lived per-account
     * cache; the rest are asked of the server. A count that cannot be
     * obtained simply leaves that folder without a badge rather than
     * failing the whole sidebar.
     *
     * @param array &$folders the folder tree to annotate in place;
     *      each selectable folder gets an UNREAD entry when known
     * @throws MailBackendException on transport failure
     */
    public function annotateUnreadCounts(&$folders)
    {
        try {
            $client = $this->client();
            $account_id = $this->accountId();
            if (empty($_SESSION["MAIL_FOLDER_UNREAD"][$account_id])) {
                $_SESSION["MAIL_FOLDER_UNREAD"][$account_id] = [];
            }
            $cache = &$_SESSION["MAIL_FOLDER_UNREAD"][$account_id];
            $now = time();
            $stats = ['cache_hits' => 0, 'status_ok' => 0,
                'status_fail' => 0, 'fail_samples' => []];
            $started = microtime(true);
            $this->walkFolderUnread($client, $folders, $cache, $now,
                $stats);
            $summary = ['cache_hits' => $stats['cache_hits'],
                'status_ok' => $stats['status_ok'],
                'status_fail' => $stats['status_fail'],
                'elapsed_ms' => (int) ((microtime(true) - $started) *
                    1000)];
            if (!empty($stats['fail_samples'])) {
                $summary['fails'] = implode("|",
                    $stats['fail_samples']);
            }
            $this->component->userMailLog('imap-probe-unread',
                $summary);
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * Walks the folder tree and sets each selectable folder's unread
     * count, asking the server only for folders whose cached count
     * has expired or is missing. Failures for one folder are tallied
     * and skipped so the rest of the tree still gets counts. Runs
     * over child folders first so nested mailboxes are covered too.
     *
     * @param object $client the live IMAP client to ask
     * @param array &$folders the folders at this level, annotated in
     *      place with an UNREAD count
     * @param array &$cache the per-account unread cache, read and
     *      updated as folders are probed
     * @param int $now the current time used to expire cache entries
     * @param array &$stats running counters and failure samples for
     *      the log line the caller writes
     */
    protected function walkFolderUnread($client, &$folders, &$cache,
        $now, &$stats)
    {
        foreach ($folders as &$folder) {
            if (!empty($folder['CHILDREN'])) {
                $this->walkFolderUnread($client, $folder['CHILDREN'],
                    $cache, $now, $stats);
            }
            if (empty($folder['SELECTABLE'])) {
                continue;
            }
            $name = $folder['NAME'];
            $cached = $cache[$name] ?? null;
            if ($cached !== null &&
                    isset($cached['unread'], $cached['expires']) &&
                    $cached['expires'] > $now) {
                $folder['UNREAD'] = (int) $cached['unread'];
                $stats['cache_hits']++;
                continue;
            }
            try {
                $status = $client->send('STATUS "' .
                    str_replace(['\\', '"'], ['\\\\', '\\"'],
                    $name) . '" (UNSEEN)');
            } catch (\Exception $exception) {
                $stats['status_fail']++;
                if (count($stats['fail_samples']) < 3) {
                    $stats['fail_samples'][] = $name . "=exc:" .
                        substr($exception->getMessage(), 0, 60);
                }
                continue;
            }
            if ($status['status'] !== 'OK') {
                $stats['status_fail']++;
                if (count($stats['fail_samples']) < 3) {
                    $stats['fail_samples'][] = $name . "=" .
                        $status['status'] . ":" . substr(
                        $status['detail'] ?? '', 0, 60);
                }
                continue;
            }
            $stats['status_ok']++;
            $unread = 0;
            foreach ($status['untagged'] as $line) {
                if (preg_match('/UNSEEN\s+(\d+)/i', $line,
                        $matches)) {
                    $unread = (int) $matches[1];
                    break;
                }
            }
            $folder['UNREAD'] = $unread;
            $cache[$name] = ['unread' => $unread,
                'expires' => $now + self::MAIL_FOLDER_UNREAD_TTL];
        }
    }
    /**
     * {@inheritdoc}
     */
    public function createFolder($name)
    {
        $this->folderOp('create', null, $name);
    }
    /**
     * {@inheritdoc}
     */
    public function renameFolder($from, $to)
    {
        $this->folderOp('rename', $from, $to);
    }
    /**
     * {@inheritdoc}
     */
    public function deleteFolder($name)
    {
        $this->folderOp('delete', $name, null);
    }
    /**
     * Lists one window of a folder's messages for the inbox view,
     * honoring the sort, the search filter, and the unread-only and
     * flagged-only toggles the request carries, and supporting both
     * the first page and later "load more" windows. This is the one
     * listing entry the inbox uses for an IMAP account; the matching
     * method on the MailSite backend serves local accounts, so the
     * caller does not special-case account type.
     *
     * The request is an array with: "window" (how many messages to
     * return), "sort" (a "key"/"reverse" pair), "filter" (a free-text
     * term, or empty), "unread_only" and "flagged_only" (the
     * toggles), "unreadable_subject" (already-translated text for a
     * message that cannot be fetched), and "cursor" (null for the
     * first page, or, for a later window, the cursor this method
     * returned as "next_cursor" last time).
     *
     * The result is an array with: "messages" (the rows, with raw
     * subject and sender for the caller to clean), "total" (how many
     * messages the count should show), "sort" (the sort actually
     * applied, which may differ from the request when a sort was too
     * large to run), "sort_supported" (whether the server can sort by
     * subject and sender), "sort_oversized" (true when a requested
     * sort was too large and the default newest-first view was shown
     * instead), "mode" ("range" for the default newest-first paging
     * by sequence number, "sorted" for paging by position in a sorted
     * list), "next_cursor" (pass back to get the following window, or
     * null when there is no more), and "has_more" (whether another
     * window exists).
     *
     * @param string $folder the folder to list
     * @param array $request the listing request described above
     * @return array the listing result described above
     */
    public function listMessages($folder, $request)
    {
        try {
            $window = (int) ($request['window'] ?? 25);
            $sort = $request['sort'] ??
                ['key' => 'date', 'reverse' => false];
            $filter = (string) ($request['filter'] ?? '');
            $unread_only = !empty($request['unread_only']);
            $flagged_only = !empty($request['flagged_only']);
            $unreadable = (string)
                ($request['unreadable_subject'] ?? '');
            $cursor = $request['cursor'] ?? null;
            /* if the caller already knows this server's sort support
               (it remembered the answer from an earlier page), take
               it on trust and skip asking again. */
            if (isset($request['sort_supported'])) {
                $this->sort_supported =
                    (bool) $request['sort_supported'];
            }
            $select_client = $this->client();
            $select_response = $select_client->send('SELECT "' .
                self::imapQuote($folder) . '"');
            if (($select_response['status'] ?? '') !== 'OK') {
                $this->selected_folder = null;
                throw new \Exception("SELECT $folder failed: " .
                    ($select_response['detail'] ?? ''));
            }
            $this->selected_folder = $folder;
            $exists = 0;
            foreach ($select_response['untagged'] as $select_line) {
                if (preg_match('/^\* (\d+) EXISTS/', $select_line,
                        $select_match)) {
                    $exists = (int) $select_match[1];
                    break;
                }
            }
            /* learn the server's sort support once, right after
               selecting, so the command order is the same whichever
               listing path runs and later checks cost nothing. */
            $this->supportsSort();
            $is_default = ($filter === '' && !$unread_only &&
                !$flagged_only && ($sort['key'] ?? 'date') === 'date'
                && empty($sort['reverse']));
            if ($exists <= 0) {
                return ['messages' => [], 'total' => 0,
                    'sort' => $sort,
                    'sort_supported' => $this->supportsSort(),
                    'sort_oversized' => false, 'mode' => 'range',
                    'next_cursor' => null, 'has_more' => false];
            }
            if ($cursor === null) {
                if ($is_default) {
                    return $this->firstRangeWindow($exists, $window,
                        $sort, $unreadable);
                }
                return $this->firstSortedWindow($folder, $exists,
                    $window, $sort, $filter, $unread_only,
                    $flagged_only, $unreadable);
            }
            if (($cursor['mode'] ?? 'range') === 'sorted') {
                $sequence = $cursor['sequence'] ?? [];
                $offset = (int) ($cursor['offset'] ?? 0);
                $next = array_slice($sequence, $offset, $window);
                $rows = empty($next) ? [] :
                    $this->adaptiveFetch($next, $unreadable);
                $new_offset = $offset + count($rows);
                $has_more = $new_offset < count($sequence);
                return ['messages' => $rows, 'total' => 0,
                    'sort' => $sort,
                    'sort_supported' => $this->supportsSort(),
                    'sort_oversized' => false, 'mode' => 'sorted',
                    'next_cursor' => $has_more ? ['mode' => 'sorted',
                        'offset' => $new_offset,
                        'sequence' => $sequence] : null,
                    'has_more' => $has_more];
            }
            $before = (int) ($cursor['before'] ?? 1);
            $upper = $before - 1;
            $from = max(1, $before - $window);
            $rows = array_reverse($this->adaptiveFetch(
                range($from, $upper), $unreadable));
            return ['messages' => $rows, 'total' => 0,
                'sort' => $sort,
                'sort_supported' => $this->supportsSort(),
                'sort_oversized' => false, 'mode' => 'range',
                'next_cursor' => $from > 1 ?
                    ['mode' => 'range', 'before' => $from] : null,
                'has_more' => $from > 1];
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * Returns the first, newest-first window of a folder by fetching
     * the contiguous block of sequence numbers that ends at the last
     * message. This is the fast default path: no sort or search is
     * sent to the server.
     *
     * @param int $exists how many messages the folder holds
     * @param int $window how many to return
     * @param array $sort the sort in effect (default date order)
     * @param string $unreadable placeholder subject text
     * @return array the listing result
     */
    protected function firstRangeWindow($exists, $window, $sort,
        $unreadable)
    {
        $from = max(1, $exists - $window + 1);
        $rows = array_reverse($this->adaptiveFetch(
            range($from, $exists), $unreadable));
        return ['messages' => $rows, 'total' => $exists,
            'sort' => $sort, 'sort_supported' => $this->supportsSort(),
            'sort_oversized' => false, 'mode' => 'range',
            'next_cursor' => $from > 1 ?
                ['mode' => 'range', 'before' => $from] : null,
            'has_more' => $from > 1];
    }
    /**
     * Returns the first window of a folder under a sort, a filter, or
     * a toggle that the default fast path cannot serve. It asks the
     * server to build the ordered list of matching sequence numbers
     * (via SORT or SEARCH), then fetches the first window of them.
     * When the sort is too large to run, it quietly falls back to the
     * default newest-first view and reports that in the result.
     *
     * @param string $folder the folder being listed
     * @param int $exists how many messages the folder holds
     * @param int $window how many to return
     * @param array $sort the requested sort
     * @param string $filter the free-text filter, or empty
     * @param bool $unread_only the unread-only toggle
     * @param bool $flagged_only the flagged-only toggle
     * @param string $unreadable placeholder subject text
     * @return array the listing result
     */
    protected function firstSortedWindow($folder, $exists, $window,
        $sort, $filter, $unread_only, $flagged_only, $unreadable)
    {
        $criteria = ImapListing::searchCriteria($filter,
            $unread_only, $flagged_only);
        if ($this->supportsSort()) {
            $plan = ImapListing::serverSortPlan($sort);
            if ($plan['mode'] === 'search') {
                $response = $this->client()->send(
                    "SEARCH $criteria", C\MAIL_SORT_TIMEOUT);
                $built = ['list' => array_reverse(
                    ImapResponseParser::parseSearch(
                    $response['untagged']))];
            } else {
                $response = $this->client()->send(
                    "SORT (" . $plan['args'] . ") UTF-8 $criteria",
                    C\MAIL_SORT_TIMEOUT);
                $built = ['list' => ImapResponseParser::parseSort(
                    $response['untagged'])];
            }
            $built['oversized'] = false;
        } else {
            $built = $this->fallbackSort($criteria, $sort);
        }
        if (!empty($built['oversized'])) {
            $default = ['key' => 'date', 'reverse' => false];
            $result = $this->firstRangeWindow($exists, $window,
                $default, $unreadable);
            $result['sort_oversized'] = true;
            return $result;
        }
        $sequence = $built['list'];
        $first = array_slice($sequence, 0, $window);
        $rows = empty($first) ? [] :
            $this->adaptiveFetch($first, $unreadable);
        $offset = count($rows);
        $total = $filter !== '' ? count($sequence) : $exists;
        $has_more = $offset < count($sequence);
        return ['messages' => $rows, 'total' => $total,
            'sort' => $sort, 'sort_supported' => $this->supportsSort(),
            'sort_oversized' => false, 'mode' => 'sorted',
            'next_cursor' => $has_more ? ['mode' => 'sorted',
                'offset' => $offset, 'sequence' => $sequence] : null,
            'has_more' => $has_more];
    }
    /**
     * Reports whether the server can sort messages for us, by asking
     * once for its capabilities and remembering the answer for the
     * life of this backend. When the answer is no, the listing code
     * sorts in PHP instead.
     *
     * @return bool true when the server advertises the SORT extension
     */
    protected function supportsSort()
    {
        if ($this->sort_supported !== null) {
            return $this->sort_supported;
        }
        $client = $this->client();
        $capability = $client->send('CAPABILITY');
        if (($capability['status'] ?? '') !== 'OK') {
            /* a dead or unhappy connection: do not remember a "no",
               so the next request can try again, and fall back to
               PHP-side sorting for now. */
            return false;
        }
        $this->sort_supported = ImapResponseParser::hasCapability(
            $capability['untagged'], 'SORT');
        return $this->sort_supported;
    }
    /**
     * Orders a matching set in PHP for servers without the SORT
     * extension. A SEARCH gives the matching messages; date order
     * then comes free from sequence order, while subject or sender
     * order needs the relevant header fetched and compared. To keep
     * a huge folder from stalling the page, a subject or sender sort
     * over more than MAIL_PHP_SORT_MAX matches is declined and
     * reported as oversized so the caller can fall back.
     *
     * @param string $criteria the SEARCH criteria to narrow the set
     * @param array $sort the requested sort
     * @return array ["list" => ordered sequence numbers,
     *      "oversized" => bool]
     */
    protected function fallbackSort($criteria, $sort)
    {
        $response = $this->client()->send("SEARCH $criteria",
            C\MAIL_SORT_TIMEOUT);
        $ids = ImapResponseParser::parseSearch($response['untagged']);
        if (empty($ids)) {
            return ['list' => [], 'oversized' => false];
        }
        if (($sort['key'] ?? 'date') === 'date') {
            return ['list' => ImapListing::fallbackDateOrder($ids,
                !empty($sort['reverse'])), 'oversized' => false];
        }
        if (count($ids) > C\MAIL_PHP_SORT_MAX) {
            return ['list' => [], 'oversized' => true];
        }
        $field = $sort['key'] === 'subject' ? 'SUBJECT' : 'FROM';
        $response = $this->client()->send("FETCH " .
            implode(',', $ids) .
            " (BODY.PEEK[HEADER.FIELDS ($field)])",
            C\MAIL_SORT_TIMEOUT);
        $values = $this->parseHeaderField($response['untagged'],
            $field);
        return ['list' => ImapListing::fallbackFieldOrder($ids,
            $values, $sort['key'], !empty($sort['reverse'])),
            'oversized' => false];
    }
    /**
     * Reads one header field out of a FETCH reply that asked for
     * just that field, returning a map from sequence number to the
     * field's value with the "FieldName:" label and surrounding
     * space removed and folded lines joined. The PHP-side sort uses
     * this to gather the values it compares.
     *
     * @param array $untagged the FETCH reply lines
     * @param string $field the field name, upper-case, as requested
     * @return array a map of sequence number to header value
     */
    protected function parseHeaderField($untagged, $field)
    {
        $values = [];
        $current_seq = 0;
        $collecting = false;
        $buffer = '';
        foreach ($untagged as $line) {
            if (preg_match('/^\* (\d+) FETCH/', $line, $match)) {
                if ($collecting && $current_seq > 0) {
                    $values[$current_seq] = trim($buffer);
                }
                $current_seq = (int) $match[1];
                $buffer = '';
                $collecting = true;
                if (preg_match('/' . preg_quote($field, '/') .
                        ':\s*(.*)$/i', $line, $field_match)) {
                    $buffer = $field_match[1];
                }
                continue;
            }
            if ($collecting && ($line === ')' || $line === '')) {
                continue;
            }
            if ($collecting) {
                $buffer .= ' ' . trim($line);
            }
        }
        if ($collecting && $current_seq > 0) {
            $values[$current_seq] = trim($buffer);
        }
        return $values;
    }
    /**
     * {@inheritdoc}
     */
    public function fetchMessage($folder, $uid)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $fetch = $client->send("UID FETCH $uid BODY.PEEK[]");
            if ($fetch['status'] !== 'OK' ||
                    empty($fetch['literals'])) {
                throw new \Exception(tl(
                    'mail_backend_message_not_found', $uid));
            }
            return reset($fetch['literals']);
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function messageHeaderBytes($folder, $uid)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $fetch = $client->send(
                "UID FETCH $uid BODY.PEEK[HEADER]");
            if ($fetch['status'] !== 'OK' ||
                    empty($fetch['literals'])) {
                throw new \Exception(tl(
                    'mail_backend_message_not_found', $uid));
            }
            return reset($fetch['literals']);
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function messageCount($folder, $unread_only = false)
    {
        try {
            $client = $this->client();
            $items = $unread_only ? '(UNSEEN)' : '(MESSAGES)';
            $status = $client->send('STATUS "' .
                self::imapQuote($folder) . "\" $items");
            if ($status['status'] !== 'OK') {
                throw new \Exception("STATUS failed: " .
                    ($status['detail'] ?? ''));
            }
            $key = $unread_only ? 'UNSEEN' : 'MESSAGES';
            foreach (($status['untagged'] ?? []) as $status_line) {
                if (preg_match('/STATUS [^(]*\(' .
                        preg_quote($key, '/') .
                        '\s+(\d+)/', $status_line, $status_match)) {
                    return (int) $status_match[1];
                }
            }
            return 0;
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function setFlagBulk($folder, $uids, $flag, $value)
    {
        if (empty($uids)) {
            return;
        }
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $op = $value ? '+FLAGS' : '-FLAGS';
            $uid_csv = implode(',', array_map('intval', $uids));
            $this->component->userMailLog('imap-bulk-flag',
                ['folder' => $folder, 'count' => count($uids),
                'op' => $op, 'flag' => $flag]);
            $store = $client->send("UID STORE $uid_csv $op " .
                "($flag)");
            if ($store['status'] !== 'OK') {
                $this->component->userMailLog(
                    'imap-bulk-flag-fail',
                    ['folder' => $folder, 'count' => count($uids),
                    'detail' => $store['detail'] ?? '']);
                throw new \Exception("UID STORE $op $flag " .
                    "bulk failed: " . ($store['detail'] ?? ''));
            }
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function moveMessagesBulk($folder, $uids, $destination)
    {
        if (empty($uids)) {
            return;
        }
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $uid_csv = implode(',', array_map('intval', $uids));
            $copy = $client->send("UID COPY $uid_csv \"" .
                self::imapQuote($destination) . '"');
            if ($copy['status'] !== 'OK') {
                throw new \Exception("UID COPY to " .
                    "$destination failed: " .
                    ($copy['detail'] ?? ''));
            }
            $store = $client->send("UID STORE $uid_csv +FLAGS " .
                "(\\Deleted)");
            if ($store['status'] !== 'OK') {
                throw new \Exception("UID STORE +Deleted " .
                    "bulk failed: " .
                    ($store['detail'] ?? ''));
            }
            /* prefer UID EXPUNGE (UIDPLUS, RFC 4315) so we only
               purge the specific UIDs we just flagged; if the
               server rejects it (no UIDPLUS), fall back to
               plain EXPUNGE which also purges any other
               pre-existing \\Deleted-flagged messages in the
               source folder. */
            $expunge = $client->send("UID EXPUNGE $uid_csv");
            if ($expunge['status'] !== 'OK') {
                $expunge = $client->send('EXPUNGE');
                if ($expunge['status'] !== 'OK') {
                    throw new \Exception("EXPUNGE bulk " .
                        "failed: " .
                        ($expunge['detail'] ?? ''));
                }
            }
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function deleteMessagesBulk($folder, $uids)
    {
        if (empty($uids)) {
            return;
        }
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $trash = $this->findSpecialFolder($client, '\\Trash',
                ['Trash', 'Deleted Items', '[Gmail]/Trash',
                'Bin']);
            if ($trash === null) {
                throw new \Exception(tl(
                    'mail_backend_no_trash_folder'));
            }
            if (strcasecmp($folder, $trash) === 0) {
                /* delete-from-Trash: skip the COPY, just mark the
                   selection deleted and expunge it in place. */
                $this->purgeMessagesBulk($folder, $uids);
                return;
            }
            $this->moveMessagesBulk($folder, $uids, $trash);
        } catch (\Exception $exception) {
            if ($exception instanceof MailBackendException) {
                throw $exception;
            }
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function purgeMessagesBulk($folder, $uids)
    {
        if (empty($uids)) {
            return;
        }
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $uid_csv = implode(',', array_map('intval', $uids));
            $store = $client->send("UID STORE $uid_csv " .
                "+FLAGS (\\Deleted)");
            if ($store['status'] !== 'OK') {
                throw new \Exception("UID STORE +Deleted " .
                    "bulk failed: " . ($store['detail'] ?? ''));
            }
            $expunge = $client->send("UID EXPUNGE $uid_csv");
            if ($expunge['status'] !== 'OK') {
                $expunge = $client->send('EXPUNGE');
                if ($expunge['status'] !== 'OK') {
                    throw new \Exception("EXPUNGE bulk " .
                        "failed: " . ($expunge['detail'] ?? ''));
                }
            }
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function setFlag($folder, $uid, $flag, $value)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $op = $value ? '+FLAGS' : '-FLAGS';
            $this->component->userMailLog('imap-flag',
                ['folder' => $folder, 'uid' => $uid,
                'op' => $op, 'flag' => $flag]);
            $store = $client->send("UID STORE $uid $op ($flag)");
            if ($store['status'] !== 'OK') {
                $this->component->userMailLog('imap-flag-fail',
                    ['folder' => $folder, 'uid' => $uid,
                    'detail' => $store['detail'] ?? '']);
                throw new \Exception("UID STORE $op $flag " .
                    "failed: " . ($store['detail'] ?? ''));
            }
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function appendMessage($folder, $bytes, $flags = [])
    {
        try {
            $client = $this->client();
            $flag_token = empty($flags) ? '' :
                '(' . implode(' ', $flags) . ')';
            $result = $client->appendMessage($folder, $flag_token,
                $bytes);
            if (!is_array($result) || empty($result['ok'])) {
                throw new \Exception("APPEND failed: " .
                    ($result['detail'] ?? ''));
            }
            /* UIDPLUS APPENDUID detection: response line of the
               form "OK [APPENDUID uidvalidity uid] ..." */
            $detail = $result['detail'] ?? '';
            if (preg_match('/APPENDUID\s+\d+\s+(\d+)/i',
                    $detail, $match)) {
                return (int) $match[1];
            }
            return null;
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function moveMessage($folder, $uid, $destination)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $copy = $client->send('UID COPY ' . (int) $uid .
                ' "' . self::imapQuote($destination) . '"');
            if ($copy['status'] !== 'OK') {
                throw new \Exception("UID COPY failed: " .
                    ($copy['detail'] ?? ''));
            }
            $store = $client->send("UID STORE $uid +FLAGS " .
                "(\\Deleted)");
            if ($store['status'] !== 'OK') {
                throw new \Exception("UID STORE +Deleted " .
                    "failed: " . ($store['detail'] ?? ''));
            }
            $expunge = $client->send("UID EXPUNGE $uid");
            if ($expunge['status'] !== 'OK') {
                /* UID EXPUNGE requires UIDPLUS; fall back to
                   plain EXPUNGE which expunges everything
                   currently \\Deleted in the folder. We just
                   STORE'd \\Deleted on a single UID so the side
                   effect on other messages is bounded to anything
                   the user previously marked \\Deleted without
                   expunging. */
                $client->send('EXPUNGE');
            }
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function deleteMessage($folder, $uid)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $trash = $this->findSpecialFolder($client, '\\Trash',
                ['Trash', 'Deleted Items', '[Gmail]/Trash',
                'Bin']);
            if ($trash === null) {
                throw new \Exception(tl(
                    'mail_backend_no_trash_folder'));
            }
            if ($trash === $folder) {
                $this->setFlag($folder, $uid, '\\Deleted', true);
                $client->send("UID EXPUNGE $uid");
                return;
            }
            $this->moveMessage($folder, $uid, $trash);
        } catch (\Exception $exception) {
            if ($exception instanceof MailBackendException) {
                throw $exception;
            }
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function purgeFolder($folder)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $store = $client->send('UID STORE 1:* +FLAGS ' .
                '(\\Deleted)');
            if ($store['status'] !== 'OK') {
                throw new \Exception("UID STORE failed: " .
                    ($store['detail'] ?? ''));
            }
            $expunge = $client->send('EXPUNGE');
            if ($expunge['status'] !== 'OK') {
                throw new \Exception("EXPUNGE failed: " .
                    ($expunge['detail'] ?? ''));
            }
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function search($folder, $query)
    {
        try {
            $client = $this->client();
            $this->selectFolder($client, $folder);
            $escaped = '"' . self::imapQuote($query) . '"';
            $cmd = "UID SEARCH OR OR SUBJECT $escaped FROM " .
                "$escaped TO $escaped";
            $result = $client->send($cmd);
            if ($result['status'] !== 'OK') {
                throw new \Exception("SEARCH failed: " .
                    ($result['detail'] ?? ''));
            }
            return $this->parseSearchUids(
                $result['untagged'] ?? []);
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * {@inheritdoc}
     */
    public function close()
    {
        if ($this->client !== null) {
            try {
                $this->client->close();
            } catch (\Exception $exception) {
                /* socket may already be dead; nothing to do. */
            }
            $this->client = null;
            $this->selected_folder = null;
        }
    }
    /**
     * Lazily opens the IMAP client, runs LOGIN, and caches the
     * client for reuse by subsequent method calls. Throws on
     * connect failure so callers do not have to special-case the
     * first vs subsequent call.
     *
     * @return object the open, logged-in IMAP client
     */
    protected function client()
    {
        if ($this->client !== null) {
            return $this->client;
        }
        $client = $this->openClient();
        $this->imapLogin($client);
        $this->client = $client;
        /* Register the reconnect callback ImapClient::send() will
           invoke on a connection-loss EOF. The handler opens a
           fresh TCP/TLS/LOGIN session through the same code path
           the initial open used (so retries inherit the account's
           TLS mode, allow-self-signed setting, and credentials),
           then transplants its socket into the dead ImapClient
           via adoptSocketFrom() so the caller's still-held
           reference becomes usable for the retry.

           A freshly-logged-in session has no SELECT state, so if
           a folder was selected before the drop we re-issue that
           SELECT on the new socket before returning. This makes
           the reconnect transparent to callers that issue raw
           folder-scoped commands directly on the client (the
           clone job's large-message ratchet loops on UID FETCH
           BODY.PEEK[<offset.size>] without going back through
           selectFolder), which would otherwise hit the upstream
           "No mailbox selected" error on the command immediately
           after a mid-stream reconnect. Callers that go through
           selectFolder() are unaffected either way thanks to its
           idempotence. If the re-SELECT itself fails the handler
           reports the reconnect as failed so send() surfaces the
           EOF rather than silently continuing folderless. The
           handler returns true on a successful reconnect, false
           otherwise; false means send() reports the EOF up to its
           caller and the caller decides how to surface it. */
        $client->setReconnectHandler(function ($dead)
                use ($client) {
            $prior_folder = $this->selected_folder;
            try {
                $fresh = $this->openClient();
                $this->imapLogin($fresh);
            } catch (\Exception $exception) {
                $this->component->userMailLog(
                    'imap-reconnect-fail',
                    ['err' => $exception->getMessage()]);
                return false;
            }
            $dead->adoptSocketFrom($fresh);
            $this->selected_folder = null;
            if ($prior_folder !== null) {
                $reselect = $dead->send('SELECT "' .
                    self::imapQuote($prior_folder) . '"');
                if ($reselect['status'] !== 'OK') {
                    $this->component->userMailLog(
                        'imap-reconnect-fail',
                        ['err' => 'reselect ' . $prior_folder .
                        ' failed: ' .
                        ($reselect['detail'] ?? '')]);
                    return false;
                }
                $this->selected_folder = $prior_folder;
            }
            $this->component->userMailLog('imap-reconnect-ok',
                ['folder' => $prior_folder ?? '']);
            return true;
        });
        return $client;
    }
    /**
     * Returns the lazily-opened, logged-in IMAP client this
     * backend uses internally, the same instance the protected
     * client() method returns. This is public so the mail clone job
     * can drive the connection directly: it issues its own folder
     * walks and large-message ratchet FETCHes on the raw client
     * rather than going through the higher-level backend methods.
     *
     * @return object the open, logged-in IMAP client
     */
    public function imapClient()
    {
        return $this->client();
    }
    /**
     * Records which folder is currently SELECT'd on this
     * backend's client. Callers that issue a raw SELECT directly
     * on imapClient() (rather than going through selectFolder)
     * must call this so the reconnect handler knows which folder
     * to re-SELECT if the socket drops mid-stream. The clone
     * job's folder walk does this: it issues its own SELECT to
     * capture UIDVALIDITY from the untagged response, which
     * selectFolder() does not expose, then notes the folder here.
     *
     * @param string $folder folder name now SELECT'd, or null to
     *      clear the tracked state
     */
    public function noteSelectedFolder($folder)
    {
        $this->selected_folder = $folder;
    }
    /**
     * Issues SELECT $folder unless the cached client is already
     * SELECT'd on the same folder. Updates the tracked selected
     * folder on success so the next call within the same folder
     * skips the round-trip.
     *
     * @param object $client the open IMAP client
     * @param string $folder folder to SELECT
     * @throws \Exception when SELECT returns non-OK
     */
    protected function selectFolder($client, $folder)
    {
        if ($this->selected_folder === $folder) {
            return;
        }
        $select = $client->send('SELECT "' .
            self::imapQuote($folder) . '"');
        if ($select['status'] !== 'OK') {
            $this->selected_folder = null;
            throw new \Exception("SELECT $folder failed: " .
                ($select['detail'] ?? ''));
        }
        $this->selected_folder = $folder;
    }
    /**
     * Common code path for create/rename/delete folder ops.
     *
     * @param string $op one of 'create', 'rename', 'delete'
     * @param string|null $source existing folder name (rename,
     *      delete) or null (create)
     * @param string|null $target new name (create, rename) or
     *      null (delete)
     */
    protected function folderOp($op, $source, $target)
    {
        try {
            $client = $this->client();
            if ($op === 'create') {
                $result = $client->send('CREATE "' .
                    self::imapQuote($target) . '"');
            } else if ($op === 'rename') {
                $result = $client->send('RENAME "' .
                    self::imapQuote($source) . '" "' .
                    self::imapQuote($target) . '"');
            } else {
                $result = $client->send('DELETE "' .
                    self::imapQuote($source) . '"');
            }
            if ($result['status'] !== 'OK') {
                throw new \Exception("$op failed: " .
                    ($result['detail'] ?? ''));
            }
            $this->selected_folder = null;
        } catch (\Exception $exception) {
            throw $this->translateException($exception);
        }
    }
    /**
     * Opens the IMAP TCP connection and TLS layer per the
     * account's TLS_MODE setting (starttls, plain, or implicit
     * imaps). Logs the open attempt and outcome.
     *
     * @return object an opened ImapClient ready for LOGIN
     * @throws \Exception on connect failure
     */
    protected function openClient()
    {
        $account = $this->account;
        $host = $account["HOST"];
        $port = (int) $account["PORT"];
        $tls_mode = $account["TLS_MODE"];
        $allow_self_signed = !empty($account["ALLOW_SELF_SIGNED"]);
        $timeout = (int) C\MAIL_IMAP_CONNECT_TIMEOUT;
        $started = microtime(true);
        $this->component->userMailLog('imap-open',
            ['host' => $host, 'port' => $port,
            'tls' => $tls_mode, 'timeout' => $timeout]);
        try {
            if ($tls_mode === 'starttls') {
                $client = ImapClient::connectStartTls($host, $port,
                    $timeout, $allow_self_signed);
            } else if ($tls_mode === 'plain') {
                $client = new ImapClient($host, $port);
            } else {
                $client = ImapClient::connectImaps($host, $port,
                    $timeout, $allow_self_signed);
            }
        } catch (\Exception $exception) {
            $this->component->userMailLog('imap-open-fail',
                ['host' => $host, 'port' => $port,
                'tls' => $tls_mode,
                'elapsed_ms' => (int) ((microtime(true) -
                    $started) * 1000),
                'err' => $exception->getMessage()]);
            throw $exception;
        }
        $this->component->userMailLog('imap-open-ok',
            ['host' => $host, 'port' => $port,
            'elapsed_ms' => (int) ((microtime(true) -
                $started) * 1000)]);
        return $client;
    }
    /**
     * Runs LOGIN on the open client with logging.
     *
     * @param object $client the open ImapClient
     */
    protected function imapLogin($client)
    {
        $account = $this->account;
        $username = $account["USERNAME"];
        $started = microtime(true);
        $this->component->userMailLog('imap-login',
            ['user' => $username]);
        $login = $client->send('LOGIN "' .
            self::imapQuote($username) . '" "' .
            self::imapQuote($account["PASSWORD"]) . '"');
        $elapsed_ms = (int) ((microtime(true) - $started) * 1000);
        if ($login['status'] === 'EOF') {
            /* server hung up before responding to LOGIN. Throw
               with the ERROR_BAD_GREETING token so mapImapException
               surfaces the localized "bad greeting" message
               instead of a raw "LOGIN failed: socket not alive"
               that mapImapException would otherwise pass through
               to the generic-fallback branch. */
            $this->component->userMailLog('imap-login-eof',
                ['user' => $username,
                'elapsed_ms' => $elapsed_ms,
                'detail' => $login['detail']]);
            throw new \Exception(
                ImapClient::ERROR_BAD_GREETING .
                ": connection closed during LOGIN");
        }
        if ($login['status'] !== 'OK') {
            $this->component->userMailLog('imap-login-fail',
                ['user' => $username,
                'elapsed_ms' => $elapsed_ms,
                'detail' => $login['detail']]);
            throw new \Exception("LOGIN failed: " .
                $login['detail']);
        }
        $this->component->userMailLog('imap-login-ok',
            ['user' => $username,
            'elapsed_ms' => $elapsed_ms]);
    }
    /**
     * Issues LIST "" "*" with timing instrumentation.
     *
     * @param object $client open ImapClient
     * @return array raw send() response
     */
    protected function imapListAll($client)
    {
        $started = microtime(true);
        $this->component->userMailLog('imap-list', []);
        $list = $client->send('LIST "" "*"');
        $elapsed_ms = (int) ((microtime(true) - $started) * 1000);
        if ($list['status'] === 'OK') {
            $this->component->userMailLog('imap-list-ok',
                ['count' => count($list['untagged'] ?? []),
                'elapsed_ms' => $elapsed_ms]);
        } else {
            $this->component->userMailLog('imap-list-fail',
                ['elapsed_ms' => $elapsed_ms,
                'detail' => $list['detail'] ?? '']);
        }
        return $list;
    }
    /**
     * Walks the cached LIST output (re-fetching when needed) to
     * resolve a SPECIAL-USE attribute to a folder name, with
     * conventional-name fallbacks for servers that do not
     * advertise SPECIAL-USE per RFC 6154.
     *
     * @param object $client open ImapClient
     * @param string $special_use the special-use attribute, e.g.
     *      '\\Sent' or '\\Trash' (with leading backslash)
     * @param array $name_fallbacks conventional names to try
     *      when no folder has the requested SPECIAL-USE
     * @return string|null the folder name or null when nothing
     *      matches
     */
    protected function findSpecialFolder($client, $special_use,
        $name_fallbacks)
    {
        $list = $this->imapListAll($client);
        if ($list['status'] !== 'OK') {
            return null;
        }
        $folders = ImapFolderListParser::parse($list['untagged']);
        foreach ($folders as $folder) {
            if (strcasecmp($folder['SPECIAL_USE'] ?? '',
                    $special_use) === 0) {
                return $folder['NAME'];
            }
        }
        foreach ($name_fallbacks as $candidate) {
            foreach ($folders as $folder) {
                if (strcasecmp($folder['NAME'], $candidate) === 0) {
                    return $folder['NAME'];
                }
            }
        }
        return null;
    }
    /**
     * Parses UID SEARCH or SEARCH ALL untagged output into a list
     * of integer UIDs.
     *
     * @param array $untagged untagged response lines
     * @return array list of ints
     */
    protected function parseSearchUids($untagged)
    {
        $uids = [];
        foreach ($untagged as $line) {
            if (preg_match('/^\* SEARCH(.*)$/', $line, $match)) {
                $parts = preg_split('/\s+/',
                    trim($match[1]));
                foreach ($parts as $part) {
                    if (ctype_digit($part)) {
                        $uids[] = (int) $part;
                    }
                }
            }
        }
        return $uids;
    }
    /**
     * Turns a FETCH reply's untagged lines into message rows keyed
     * by sequence number, the shape the sortable inbox view and its
     * scroll-by-position pagination expect. Each row carries the
     * sequence number, the UID, the raw flags plus the read,
     * answered, and flagged states read from them, and the date,
     * subject, sender address, and sender display name pulled from
     * the message envelope.
     *
     * The subject, sender, and sender name are returned exactly as
     * the remote server sent them, with no cleaning. They arrive
     * off the wire and end up echoed into the inbox HTML, so the
     * controller that receives these rows must clean them before
     * they are shown; doing it here would put display concerns in
     * a library class.
     *
     * @param array $untagged the untagged lines of a FETCH reply
     * @return array message rows in the order the lines appeared,
     *      each with SEQ, UID, FLAGS, IS_UNREAD, IS_ANSWERED,
     *      IS_FLAGGED, DATE, DATE_TS, SUBJECT, FROM, and FROM_NAME;
     *      SUBJECT, FROM, and FROM_NAME are raw and need cleaning
     */
    protected function parseSeqEnvelopes($untagged)
    {
        $messages = [];
        foreach ($untagged as $line) {
            if (!preg_match('/UID (\d+)/', $line, $uid_match)) {
                continue;
            }
            $uid = (int) $uid_match[1];
            $seq = 0;
            if (preg_match('/^\* (\d+) FETCH/', $line, $seq_match)) {
                $seq = (int) $seq_match[1];
            }
            $message = ["SEQ" => $seq, "UID" => $uid, "FLAGS" => "",
                "IS_UNREAD" => true, "IS_ANSWERED" => false,
                "IS_FLAGGED" => false, "DATE" => "", "SUBJECT" => "",
                "FROM" => ""];
            if (preg_match('/FLAGS \(([^)]*)\)/', $line,
                    $flag_match)) {
                $message["FLAGS"] = $flag_match[1];
                /* IMAP system flags are case-insensitive; match each
                   as a whole token so a future keyword whose name
                   starts with "Seen" (and so on) is not mistaken for
                   the system flag. */
                foreach (preg_split('/\s+/', $flag_match[1])
                        as $token) {
                    if (strcasecmp($token, "\\Seen") === 0) {
                        $message["IS_UNREAD"] = false;
                    } else if (strcasecmp($token, "\\Answered")
                            === 0) {
                        $message["IS_ANSWERED"] = true;
                    } else if (strcasecmp($token, "\\Flagged")
                            === 0) {
                        $message["IS_FLAGGED"] = true;
                    }
                }
            }
            $envelope = ImapEnvelopeParser::parse($line);
            $message["DATE"] = $envelope["DATE"];
            $message["DATE_TS"] = $envelope["DATE"] !== '' ?
                ((int) strtotime($envelope["DATE"])) : 0;
            $message["SUBJECT"] = $envelope["SUBJECT"];
            $message["FROM"] = $envelope["FROM"];
            $message["FROM_NAME"] = ImapListing::extractFromName(
                $envelope["FROM"]);
            $messages[] = $message;
        }
        return $messages;
    }
    /**
     * Fetches the envelopes for a list of sequence numbers while
     * staying resilient to a server that struggles with large
     * requests. It starts with a modest batch, doubles the batch on
     * each success so a healthy server is soon fetching large runs
     * at once, and halves the batch whenever one fails. A single
     * message that fails even on its own (the server has a corrupt
     * copy or keeps dropping it) is replaced with a placeholder row
     * so one bad message cannot blank the whole inbox. The result is
     * returned in the requested order with any unfetchable messages
     * standing in as placeholders, so the order the person chose is
     * preserved with no gaps.
     *
     * The batch-size rules and result ordering live in ImapListing
     * so they can be checked without a live server; this method does
     * the talking to the server and applies them.
     *
     * @param array $sequence_numbers the sequence numbers to fetch,
     *      in the order they should appear
     * @param string $unreadable_subject the already-translated text
     *      to show as the subject of a message that could not be
     *      fetched
     * @return array the message rows in the requested order, with a
     *      placeholder row in place of any that could not be fetched
     */
    protected function adaptiveFetch($sequence_numbers,
        $unreadable_subject)
    {
        $total = count($sequence_numbers);
        $position = 0;
        $batch = ImapListing::initialBatch($total);
        $streak = 0;
        $by_seq = [];
        while ($position < $total) {
            $candidate = array_slice($sequence_numbers, $position,
                $batch);
            try {
                $batch_client = $this->client();
                $batch_fetch = $batch_client->send("FETCH " .
                    implode(",", $candidate) .
                    " (UID FLAGS ENVELOPE)");
                if (($batch_fetch['status'] ?? '') !== 'OK') {
                    $outcome = ['ok' => false, 'by_seq' => []];
                } else {
                    $batch_by_seq = [];
                    foreach ($this->parseSeqEnvelopes(
                            $batch_fetch['untagged']) as $batch_row) {
                        $batch_by_seq[$batch_row['SEQ']] =
                            $batch_row;
                    }
                    $outcome = ['ok' => true,
                        'by_seq' => $batch_by_seq];
                }
            } catch (\Exception $batch_exception) {
                $outcome = ['ok' => false, 'by_seq' => []];
            }
            if ($outcome['ok']) {
                foreach ($outcome['by_seq'] as $seq => $row) {
                    $by_seq[$seq] = $row;
                }
                $position += count($candidate);
                $streak++;
                $batch = ImapListing::growBatch($total - $position,
                    $streak);
                continue;
            }
            if ($batch === 1) {
                $bad_seq = $candidate[0];
                $this->component->userMailLog(
                    'imap-fetch-batch-fail',
                    ['seq' => $bad_seq, 'placeholder' => 1]);
                $by_seq[$bad_seq] = ImapListing::placeholderRow(
                    $bad_seq, $unreadable_subject);
                $position++;
                $batch = 1;
                $streak = 0;
                continue;
            }
            $batch = ImapListing::shrinkBatch($batch);
            $streak = 0;
        }
        return ImapListing::orderBySequence($by_seq,
            $sequence_numbers);
    }
    /**
     * Translates a raw \Exception thrown from the IMAP client
     * (or this backend's own internal helpers) into a
     * MailBackendException whose message is already localized
     * via tl(). The translation table matches the legacy
     * userMailImapErrorMessage helper in SocialComponent so user-
     * visible error wording is consistent across the migration.
     *
     * @param \Exception $exception the raw exception
     * @return MailBackendException ready to throw to the caller
     */
    protected function translateException($exception)
    {
        $raw = $exception->getMessage();
        $token = $raw;
        $detail = "";
        $colon = strpos($raw, ": ");
        if ($colon !== false) {
            $token = substr($raw, 0, $colon);
            $detail = substr($raw, $colon + 2);
        }
        $map = [
            ImapClient::ERROR_CERT_VERIFY =>
                tl('social_component_mail_err_cert_verify'),
            ImapClient::ERROR_CERT_HOSTNAME =>
                tl('social_component_mail_err_cert_hostname'),
            ImapClient::ERROR_CERT_EXPIRED =>
                tl('social_component_mail_err_cert_expired'),
            ImapClient::ERROR_SECURE_CONNECT =>
                tl('social_component_mail_err_secure_connect'),
            ImapClient::ERROR_CONNECTION_REFUSED =>
                tl('social_component_mail_err_connection_refused'),
            ImapClient::ERROR_TIMED_OUT =>
                tl('social_component_mail_err_timed_out'),
            ImapClient::ERROR_HOST_NOT_FOUND =>
                tl('social_component_mail_err_host_not_found'),
            ImapClient::ERROR_CONNECT_FAILED =>
                tl('social_component_mail_err_connect_failed'),
            ImapClient::ERROR_BAD_GREETING =>
                tl('social_component_mail_err_bad_greeting'),
        ];
        /* Connection-level failures: the socket dropped, the
           server hung up, or it never answered. These are worth
           retrying on a later run rather than treating as fatal,
           so the clone job can resume instead of failing the whole
           job. Cert errors are deliberately excluded -- they will
           not fix themselves on retry. */
        $transient_tokens = [
            ImapClient::ERROR_SECURE_CONNECT => true,
            ImapClient::ERROR_CONNECTION_REFUSED => true,
            ImapClient::ERROR_TIMED_OUT => true,
            ImapClient::ERROR_HOST_NOT_FOUND => true,
            ImapClient::ERROR_CONNECT_FAILED => true,
            ImapClient::ERROR_BAD_GREETING => true,
        ];
        if (isset($map[$token])) {
            $message = $map[$token];
            if ($detail !== "") {
                $message .= " ($detail)";
            }
            $translated = new MailBackendException($message, 0,
                $exception);
            if (isset($transient_tokens[$token])) {
                $translated->setTransient(true);
            }
            return $translated;
        }
        /* A dropped socket mid-command surfaces as a raw
           "... failed: read failed mid-response" (or a "socket not
           alive" / EOF detail) that did not carry one of the mapped
           connection tokens; still treat it as transient. */
        $transient_markers = ['read failed mid-response',
            'socket not alive', 'connection closed'];
        $is_transient = false;
        foreach ($transient_markers as $marker) {
            if (stripos($raw, $marker) !== false) {
                $is_transient = true;
                break;
            }
        }
        $generic = new MailBackendException(tl(
            'social_component_mail_err_generic', $raw), 0,
            $exception);
        $generic->setTransient($is_transient);
        return $generic;
    }
    /**
     * Quotes a value for inclusion inside an IMAP-protocol
     * double-quoted string per RFC 3501 sec 4.3: escapes
     * embedded backslashes and double quotes by prepending a
     * backslash, drops bare CR and LF characters since they
     * cannot appear inside a quoted-string.
     *
     * @param string $value the raw value to quote
     * @return string the quoted form, ready to embed inside
     *      surrounding double quotes
     */
    public static function imapQuote($value)
    {
        $value = (string) $value;
        $value = str_replace(["\\", '"'], ["\\\\", '\\"'], $value);
        return str_replace(["\r", "\n"], '', $value);
    }
}
X