/ tests / ImapMailBackendTest.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\tests;

use seekquarry\yioop\library\mail\ImapMailBackend;
use seekquarry\yioop\library\UnitTest;

/**
 * Unit tests for the resilient message-listing parts of
 * ImapMailBackend: the sequence-keyed envelope parser and the
 * adaptive fetch loop that keeps an inbox view filling even when a
 * server struggles with large requests or has a corrupt message.
 *
 * The tests drive the backend with a scripted stand-in for the IMAP
 * client, so each rule (batch growth, halving on failure, a
 * placeholder for a message that cannot be fetched, order
 * preservation) is checked with no live mail server. The backend's
 * protected methods are reached through a tiny subclass below.
 *
 * @author Chris Pollett
 */
class ImapMailBackendTest extends UnitTest
{
    /**
     * The backend under test, rebuilt before each case
     * @var object
     */
    public $backend;
    /**
     * The scripted stand-in IMAP client the backend talks to
     * @var object
     */
    public $client;
    /**
     * Builds a backend wired to a scripted client and a do-nothing
     * logger, ready for a case to script replies and call in.
     */
    public function setUp()
    {
        $this->client = new FakeListingClient();
        $this->backend = new TestableImapBackend([],
            new FakeListingComponent());
        $this->backend->useClient($this->client);
    }
    /**
     * No tearDown
     */
    public function tearDown()
    {
    }
    /**
     * defaultFolder returns the account's configured folder, and
     * falls back to INBOX when none is set on the account.
     */
    public function defaultFolderTestCase()
    {
        $this->assertEqual("INBOX", $this->backend->defaultFolder(),
            "an account with no default folder opens to INBOX");
        $configured = new TestableImapBackend(
            ["DEFAULT_FOLDER" => "Archive"],
            new FakeListingComponent());
        $this->assertEqual("Archive", $configured->defaultFolder(),
            "a configured default folder is used");
    }
    /**
     * annotateUnreadCounts asks the server for each selectable
     * folder's unread count and records it, then serves a second
     * pass from the cache without asking the server again.
     */
    public function annotateUnreadCountsTestCase()
    {
        $_SESSION = [];
        $backend = new TestableImapBackend(["ID" => 7],
            new FakeListingComponent());
        $backend->useClient($this->client);
        $this->client->responses = [
            ['status' => 'OK',
                'untagged' => ['* STATUS "INBOX" (UNSEEN 3)']],
            ['status' => 'OK',
                'untagged' => ['* STATUS "Sent" (UNSEEN 0)']]];
        $folders = [
            ['NAME' => 'INBOX', 'SELECTABLE' => true],
            ['NAME' => 'Sent', 'SELECTABLE' => true]];
        $backend->annotateUnreadCounts($folders);
        $this->assertEqual(3, $folders[0]['UNREAD'],
            'INBOX unread count comes from the STATUS reply');
        $this->assertEqual(0, $folders[1]['UNREAD'],
            'Sent unread count comes from the STATUS reply');
        $sent_after_first = count($this->client->sent);
        $folders_again = [
            ['NAME' => 'INBOX', 'SELECTABLE' => true],
            ['NAME' => 'Sent', 'SELECTABLE' => true]];
        $backend->annotateUnreadCounts($folders_again);
        $this->assertEqual(3, $folders_again[0]['UNREAD'],
            'the second pass serves INBOX from the cache');
        $this->assertEqual($sent_after_first,
            count($this->client->sent),
            'the cached pass sends no further STATUS commands');
        $_SESSION = [];
    }
    /**
     * Builds one FETCH untagged line carrying a UID, a flags list,
     * and an envelope, in the form a real server sends, so the
     * parser and loop get realistic input.
     *
     * @param int $seq the sequence number for the line
     * @param int $uid the message UID
     * @param string $flags the flags list contents, for example
     *      "\\Seen" or the empty string
     * @param string $subject the subject to embed in the envelope
     * @return string a single untagged FETCH line
     */
    public function fetchLine($seq, $uid, $flags, $subject)
    {
        return '* ' . $seq . ' FETCH (UID ' . $uid . ' FLAGS (' .
            $flags . ') ENVELOPE (' .
            '"Wed, 13 May 2026 20:59:19 +0000" "' . $subject . '" ' .
            '(("Jane Roe" NIL "jane" "example.com")) ' .
            '(("Jane Roe" NIL "jane" "example.com")) ' .
            '(("Jane Roe" NIL "jane" "example.com")) ' .
            '(("You" NIL "you" "pollett.org")) NIL NIL NIL ' .
            '"<msg-' . $uid . '@example.com>"))';
    }
    /**
     * A successful FETCH reply, ready to push onto the client's
     * script.
     *
     * @param array $untagged the untagged lines to return
     * @return array a reply with OK status
     */
    public function okReply($untagged)
    {
        return ['status' => 'OK', 'untagged' => $untagged,
            'detail' => ''];
    }
    /**
     * parseSeqEnvelopes should read the sequence number, UID, flag
     * states, and envelope fields off a FETCH line, leave the
     * subject and sender raw for the controller to clean, and derive
     * the sender display name.
     */
    public function parseSeqEnvelopesTestCase()
    {
        $line = $this->fetchLine(5, 100, '\\Seen', 'Hello there');
        $rows = $this->backend->callParseSeqEnvelopes([$line]);
        $this->assertEqual(1, count($rows), "one line yields one row");
        $row = $rows[0];
        $this->assertEqual(5, $row['SEQ'], "sequence number is read");
        $this->assertEqual(100, $row['UID'], "UID is read");
        $this->assertFalse($row['IS_UNREAD'],
            "the Seen flag marks the message read");
        $this->assertEqual('Hello there', $row['SUBJECT'],
            "subject comes through raw");
        $this->assertEqual('Jane Roe <jane@example.com>',
            $row['FROM'], "sender address is formatted from envelope");
        $this->assertEqual('Jane Roe', $row['FROM_NAME'],
            "sender display name is derived");
    }
    /**
     * parseSeqEnvelopes should treat a message with no Seen flag as
     * unread.
     */
    public function parseSeqEnvelopesUnreadTestCase()
    {
        $line = $this->fetchLine(6, 101, '', 'Unseen one');
        $rows = $this->backend->callParseSeqEnvelopes([$line]);
        $this->assertTrue($rows[0]['IS_UNREAD'],
            "no Seen flag means the message is unread");
    }
    /**
     * adaptiveFetch should return one row per sequence number, in
     * the requested order, when a single batch succeeds.
     */
    public function adaptiveFetchAllAtOnceTestCase()
    {
        $this->client->responses = [
            $this->okReply([
                $this->fetchLine(1, 100, '', 'One'),
                $this->fetchLine(2, 101, '', 'Two'),
                $this->fetchLine(3, 102, '', 'Three'),
            ]),
        ];
        $rows = $this->backend->callAdaptiveFetch([1, 2, 3],
            'Unreadable');
        $this->assertEqual([1, 2, 3],
            [$rows[0]['SEQ'], $rows[1]['SEQ'], $rows[2]['SEQ']],
            "rows come back in the requested order");
        $this->assertEqual(1, count($this->client->sent),
            "a clean fetch takes a single request");
    }
    /**
     * adaptiveFetch should recover from a failed large batch by
     * halving and retrying, and still return every message.
     */
    public function adaptiveFetchHalvesOnFailureTestCase()
    {
        /* four messages: the first batch of four fails, so the loop
           halves to two and fetches [1,2], then grows back to two
           and fetches [3,4]. */
        $this->client->responses = [
            ['status' => 'NO', 'untagged' => [], 'detail' => 'busy'],
            $this->okReply([
                $this->fetchLine(1, 100, '', 'One'),
                $this->fetchLine(2, 101, '', 'Two'),
            ]),
            $this->okReply([
                $this->fetchLine(3, 102, '', 'Three'),
                $this->fetchLine(4, 103, '', 'Four'),
            ]),
        ];
        $rows = $this->backend->callAdaptiveFetch([1, 2, 3, 4],
            'Unreadable');
        $this->assertEqual(4, count($rows),
            "every message is recovered after halving");
        $this->assertEqual([1, 2, 3, 4],
            array_map(function ($row) {
                return $row['SEQ'];
            }, $rows),
            "recovered rows keep the requested order");
    }
    /**
     * adaptiveFetch should drop in a placeholder for a single
     * message that fails even on its own, keep the good messages
     * around it, and mark the placeholder with the passed-in
     * subject.
     */
    public function adaptiveFetchPlaceholderTestCase()
    {
        /* two messages: the batch of two fails, the loop halves to
           one, fetches seq 1 fine, then seq 2 fails on its own and
           becomes a placeholder. */
        $this->client->responses = [
            ['status' => 'NO', 'untagged' => [], 'detail' => 'busy'],
            $this->okReply([$this->fetchLine(1, 100, '', 'One')]),
            ['status' => 'NO', 'untagged' => [], 'detail' => 'bad'],
        ];
        $rows = $this->backend->callAdaptiveFetch([1, 2],
            'Unreadable message');
        $this->assertEqual(2, count($rows),
            "the good message and the placeholder both appear");
        $this->assertEqual('One', $rows[0]['SUBJECT'],
            "the fetchable message keeps its subject");
        $this->assertTrue(!empty($rows[1]['IS_PLACEHOLDER']),
            "the unfetchable message becomes a placeholder");
        $this->assertEqual('Unreadable message', $rows[1]['SUBJECT'],
            "the placeholder shows the passed-in subject");
    }
    /**
     * A SELECT reply carrying an EXISTS count.
     *
     * @param int $exists the message count to report
     * @return array a SELECT reply
     */
    public function selectReply($exists)
    {
        return ['status' => 'OK',
            'untagged' => ['* ' . $exists . ' EXISTS'],
            'detail' => ''];
    }
    /**
     * A CAPABILITY reply that does or does not advertise SORT.
     *
     * @param bool $has_sort whether to advertise the SORT extension
     * @return array a CAPABILITY reply
     */
    public function capabilityReply($has_sort)
    {
        $line = '* CAPABILITY IMAP4rev1' .
            ($has_sort ? ' SORT' : '');
        return ['status' => 'OK', 'untagged' => [$line],
            'detail' => ''];
    }
    /**
     * A SORT reply listing sequence numbers in order.
     *
     * @param array $seqs the ordered sequence numbers
     * @return array a SORT reply
     */
    public function sortReply($seqs)
    {
        return ['status' => 'OK',
            'untagged' => ['* SORT ' . implode(' ', $seqs)],
            'detail' => ''];
    }
    /**
     * A FETCH reply with one envelope line per sequence number.
     *
     * @param array $seqs the sequence numbers to include
     * @return array a FETCH reply
     */
    public function fetchReply($seqs)
    {
        $lines = [];
        foreach ($seqs as $seq) {
            $lines[] = $this->fetchLine($seq, 100 + $seq, '',
                'Subject ' . $seq);
        }
        return $this->okReply($lines);
    }
    /**
     * listMessages on the default newest-first view should select,
     * range-fetch the newest window, and return the rows newest-
     * first with no sort or search sent.
     */
    public function listMessagesFastPathTestCase()
    {
        $this->client->responses = [
            $this->selectReply(3),
            $this->capabilityReply(false),
            $this->fetchReply([1, 2, 3]),
        ];
        $result = $this->backend->listMessages('INBOX',
            ['window' => 25, 'unreadable_subject' => 'x']);
        $this->assertEqual('range', $result['mode'],
            "the default view pages by sequence range");
        $this->assertEqual([3, 2, 1],
            array_map(function ($row) {
                return $row['SEQ'];
            }, $result['messages']),
            "rows come back newest-first");
        $this->assertEqual(3, $result['total'],
            "total reflects the folder's message count");
        $this->assertFalse($result['has_more'],
            "a window covering the whole folder has no more");
    }
    /**
     * listMessages with a non-default sort should ask the server to
     * SORT and then fetch the messages in the order it returned.
     */
    public function listMessagesServerSortTestCase()
    {
        $this->client->responses = [
            $this->selectReply(3),
            $this->capabilityReply(true),
            $this->sortReply([2, 1, 3]),
            $this->fetchReply([2, 1, 3]),
        ];
        $result = $this->backend->listMessages('INBOX',
            ['window' => 25, 'unreadable_subject' => 'x',
            'sort' => ['key' => 'subject', 'reverse' => false]]);
        $this->assertEqual('sorted', $result['mode'],
            "a non-default sort pages by position in the sorted list");
        $this->assertEqual([2, 1, 3],
            array_map(function ($row) {
                return $row['SEQ'];
            }, $result['messages']),
            "rows follow the server's SORT order");
        $this->assertTrue(strpos(implode('|', $this->client->sent),
            'SORT (SUBJECT)') !== false,
            "a SORT command was issued for the subject sort");
    }
    /**
     * When the request carries a sort_supported hint, listMessages
     * trusts it and does not ask the server for its capabilities.
     */
    public function listMessagesSortHintTestCase()
    {
        $this->client->responses = [
            $this->selectReply(3),
            $this->sortReply([2, 1, 3]),
            $this->fetchReply([2, 1, 3]),
        ];
        $result = $this->backend->listMessages('INBOX',
            ['window' => 25, 'unreadable_subject' => 'x',
            'sort_supported' => true,
            'sort' => ['key' => 'subject', 'reverse' => false]]);
        $this->assertEqual([2, 1, 3],
            array_map(function ($row) {
                return $row['SEQ'];
            }, $result['messages']),
            "the sorted view still works using the hint");
        $this->assertTrue(strpos(implode('|', $this->client->sent),
            'CAPABILITY') === false,
            "no CAPABILITY was sent when the hint was supplied");
    }
    /**
     * listMessages with a range cursor should fetch the older window
     * just below the oldest sequence already shown.
     */
    public function listMessagesLoadMoreRangeTestCase()
    {
        $this->client->responses = [
            $this->selectReply(20),
            $this->capabilityReply(false),
            $this->fetchReply([5, 6, 7, 8, 9]),
        ];
        $result = $this->backend->listMessages('INBOX',
            ['window' => 5, 'unreadable_subject' => 'x',
            'cursor' => ['mode' => 'range', 'before' => 10]]);
        $this->assertEqual([9, 8, 7, 6, 5],
            array_map(function ($row) {
                return $row['SEQ'];
            }, $result['messages']),
            "the older window returns newest-first");
        $this->assertTrue($result['has_more'],
            "more remains below sequence five");
        $this->assertEqual(5, $result['next_cursor']['before'],
            "the next cursor stops just below this window");
    }
}
/**
 * A scripted stand-in for ImapClient. Each call to send() returns
 * the next reply queued in $responses (or throws it, when the queued
 * item is an exception) and records the command in $sent, so a test
 * can both drive the loop and check what it asked for.
 *
 * @author Chris Pollett
 */
class FakeListingClient
{
    /**
     * Queue of replies (or exceptions) send() hands back in turn
     * @var array
     */
    public $responses = [];
    /**
     * Commands that have been sent, in order
     * @var array
     */
    public $sent = [];
    /**
     * Records the command and returns (or throws) the next scripted
     * reply.
     *
     * @param string $command the IMAP command line
     * @param mixed $timeout ignored; present to match the real
     *      client's shape
     * @return array the next scripted reply
     */
    public function send($command, $timeout = null)
    {
        $this->sent[] = $command;
        $next = array_shift($this->responses);
        if ($next instanceof \Exception) {
            throw $next;
        }
        return $next;
    }
}
/**
 * A do-nothing stand-in for the component the backend logs through,
 * so listing tests do not write to the mail log.
 *
 * @author Chris Pollett
 */
class FakeListingComponent
{
    /**
     * Swallows a log call.
     *
     * @param string $tag the event tag
     * @param array $fields the log fields
     */
    public function userMailLog($tag, $fields = [])
    {
    }
}
/**
 * Exposes the backend's protected listing methods so the test can
 * call them directly.
 *
 * @author Chris Pollett
 */
class TestableImapBackend extends ImapMailBackend
{
    /**
     * Injects a stand-in client so the listing methods talk to it
     * instead of opening a real connection.
     *
     * @param object $client the stand-in IMAP client
     */
    public function useClient($client)
    {
        $this->client = $client;
    }
    /**
     * Calls the protected parseSeqEnvelopes.
     *
     * @param array $untagged untagged FETCH lines
     * @return array the parsed rows
     */
    public function callParseSeqEnvelopes($untagged)
    {
        return $this->parseSeqEnvelopes($untagged);
    }
    /**
     * Calls the protected adaptiveFetch.
     *
     * @param array $sequence_numbers the sequence numbers to fetch
     * @param string $unreadable_subject placeholder subject text
     * @return array the resulting rows
     */
    public function callAdaptiveFetch($sequence_numbers,
        $unreadable_subject)
    {
        return $this->adaptiveFetch($sequence_numbers,
            $unreadable_subject);
    }
}
X