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