<?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\atto\MailSite;
use seekquarry\atto\RamMailStorage;
use seekquarry\yioop\library\mail\MailSiteFactory;
use seekquarry\yioop\library\mail\MailSiteMailBackend;
use seekquarry\yioop\library\UnitTest;
/**
* MailSite subclass used only by the large-folder test below. It
* counts how many times a message's header bytes are read, so the
* test can assert that listing one window of a big folder reads
* the files for just that window rather than for every message in
* the folder.
*/
class CountingHeaderMailSite extends MailSite
{
/**
* Running count of messageHeaderBytes calls since the instance
* was built; the test zeroes it after seeding and checks it
* after one listing.
* @var int
*/
public $header_read_count = 0;
/**
* Tallies the read, then defers to the normal storage read so
* the returned bytes are exactly what the production path sees.
* @param string $user mailbox owner username (no @domain)
* @param string $folder folder the message lives in
* @param int $uid message unique identifier
* @return string|false the header bytes, or false when absent
*/
public function messageHeaderBytes($user, $folder, $uid)
{
$this->header_read_count++;
return parent::messageHeaderBytes($user, $folder, $uid);
}
}
/**
* Tests MailSiteMailBackend against a RamMailStorage-backed
* MailSite, installed via MailSiteFactory's test override seam.
* Each test starts from an empty mailbox; setUp seeds one
* INBOX message so list / fetch operations have something to
* exercise.
*
* @author Chris Pollett
*/
class MailSiteMailBackendTest extends UnitTest
{
/**
* Test username -- the synthetic backend ties to the
* currently signed-in Yioop user via the username, but the
* backend itself does not enforce account ownership (the
* factory does, upstream), so any non-empty string works
* here.
* @var string
*/
public $username = 'tester';
/**
* The MailSite instance built around RamMailStorage that
* the test override installs; held so tests that need to
* inject extra messages have access to the same storage
* the backend sees.
* @var MailSite
*/
public $mail_site;
/**
* Backend under test.
* @var MailSiteMailBackend
*/
public $backend;
/**
* Sample raw RFC 5322 bytes; defined once and reused
* across tests that need a deliverable message.
* @var string
*/
public $sample_rfc822;
/**
* Builds a fresh in-memory MailSite, installs it as the
* factory's test override, and constructs the backend
* under test. Seeds the INBOX with one sample message so
* listMessages / fetchMessage have something to read.
*/
public function setUp()
{
$this->mail_site = new MailSite();
$this->mail_site->storage(new RamMailStorage());
MailSiteFactory::setTestOverride($this->mail_site);
/* component is unused by MailSiteMailBackend; pass null. */
$this->backend = new MailSiteMailBackend($this->username,
null);
$this->sample_rfc822 = "From: alice@example.com\r\n" .
"To: tester@example.com\r\n" .
"Subject: Hello from setUp\r\n" .
"Date: Mon, 1 Jan 2026 00:00:00 +0000\r\n" .
"Message-ID: <setup@example>\r\n" .
"MIME-Version: 1.0\r\n" .
"Content-Type: text/plain; charset=UTF-8\r\n" .
"\r\n" .
"Body of the seed message.\r\n";
$this->backend->appendMessage('INBOX',
$this->sample_rfc822);
}
/**
* Clears the factory test override so subsequent tests do
* not see this test's MailSite instance.
*/
public function tearDown()
{
MailSiteFactory::setTestOverride(null);
}
/**
* accountId / isSyntheticAccount / displayName / senderEmail
* return the documented constants for the synthetic account.
*/
public function syntheticAccountShapeTestCase()
{
$backend = $this->backend;
$this->assertEqual(0, $backend->accountId(),
'Synthetic accountId is MAILSITE_ACCOUNT_ID = 0');
$this->assertTrue($backend->isSyntheticAccount(),
'isSyntheticAccount is true for MailSite backend');
$this->assertEqual("INBOX", $backend->defaultFolder(),
'the synthetic mailbox opens to INBOX by default');
$this->assertEqual($this->username,
$backend->displayName(),
'displayName is the username for MailSite');
/* senderEmail composes local-part + primary domain so the
From: header carries an addressable email; in the test
environment MAIL_DOMAINS is empty and localDomains()
falls back to ['localhost']. */
$domains = MailSiteFactory::localDomains();
$expected = $this->username . '@' . $domains[0];
$this->assertEqual($expected, $backend->senderEmail(),
'senderEmail composes username + primary local domain');
}
/**
* defaultFolders is the static catalog every MailSite
* mailbox starts with: INBOX, Sent, Drafts, Junk, Trash,
* with the matching RFC 6154 special-use attributes on
* Sent/Drafts/Junk/Trash. INBOX has no special-use.
*/
public function defaultFoldersCatalogTestCase()
{
$defaults = MailSiteMailBackend::defaultFolders();
$by_name = [];
foreach ($defaults as $row) {
$by_name[$row['NAME']] = $row;
}
$this->assertTrue(isset($by_name['INBOX']),
'INBOX is in the default catalog');
$this->assertTrue(isset($by_name['Sent']),
'Sent is in the default catalog');
$this->assertTrue(isset($by_name['Drafts']),
'Drafts is in the default catalog');
$this->assertTrue(isset($by_name['Junk']),
'Junk is in the default catalog');
$this->assertTrue(isset($by_name['Trash']),
'Trash is in the default catalog');
$this->assertEqual('', $by_name['INBOX']['SPECIAL_USE'],
'INBOX has no SPECIAL_USE attribute');
$this->assertEqual('\\Sent',
$by_name['Sent']['SPECIAL_USE'],
'Sent has \\Sent special-use attribute');
$this->assertEqual('\\Junk',
$by_name['Junk']['SPECIAL_USE'],
'Junk has \\Junk special-use attribute');
$this->assertEqual('\\Trash',
$by_name['Trash']['SPECIAL_USE'],
'Trash has \\Trash special-use attribute');
}
/**
* listFolders surfaces every default folder even when the
* underlying storage only has the seeded INBOX. The merge
* logic in MailSiteMailBackend.listFolders synthesises the
* missing defaults so the sidebar always shows the
* standard folder set.
*/
public function listFoldersDefaultsMergeTestCase()
{
$folders = $this->backend->listFolders();
$names = [];
foreach ($folders as $row) {
$names[] = $row['NAME'];
}
$this->assertTrue(in_array('INBOX', $names, true),
'INBOX appears in listFolders output');
$this->assertTrue(in_array('Sent', $names, true),
'Sent appears even when not yet in storage');
$this->assertTrue(in_array('Trash', $names, true),
'Trash appears even when not yet in storage');
}
/**
* appendMessage on a custom (non-default) folder name
* surfaces that folder via listFolders on the next call.
* The custom folder appears alongside the defaults; its
* SPECIAL_USE is empty since it does not match any default.
*/
public function listFoldersUserCreatedTestCase()
{
$this->backend->appendMessage('Projects',
$this->sample_rfc822);
$folders = $this->backend->listFolders();
$found = null;
foreach ($folders as $row) {
if ($row['NAME'] === 'Projects') {
$found = $row;
break;
}
}
$this->assertTrue($found !== null,
'User-created folder appears in listFolders');
$this->assertEqual('', $found['SPECIAL_USE'],
'User-created folder has empty SPECIAL_USE');
}
/**
* listMessages on the seeded INBOX returns the one message
* with its envelope fields parsed out.
*/
public function listMessagesSeedTestCase()
{
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertEqual(1, count($messages),
'Seeded INBOX has one message');
$this->assertEqual('Hello from setUp',
$messages[0]['SUBJECT'],
'Subject parsed from envelope');
}
/**
* Listing one window of a large folder reads message files
* only for the messages on that window, not for every message
* in the folder. This guards the window-then-shape path: were
* the listing to shape every record up front it would read one
* header block per message, which an SSD hides but a spinning
* disk turns into minutes of seeks that freeze the single-
* process web server while a big mailbox is listed.
*/
public function listMessagesWindowReadsOnlyWindowTestCase()
{
$counting_site = new CountingHeaderMailSite();
$counting_site->storage(new RamMailStorage());
MailSiteFactory::setTestOverride($counting_site);
$backend = new MailSiteMailBackend($this->username, null);
$message_total = 60;
for ($index = 0; $index < $message_total; $index++) {
$raw = "From: alice@example.com\r\n" .
"To: tester@example.com\r\n" .
"Subject: Message $index\r\n" .
"Date: Mon, 1 Jan 2026 00:00:00 +0000\r\n" .
"\r\n" .
"Body $index\r\n";
$backend->appendMessage('INBOX', $raw);
}
$counting_site->header_read_count = 0;
$window = 10;
$result = $backend->listMessages('INBOX',
['window' => $window]);
$this->assertEqual($window, count($result['messages']),
'one window returns exactly the window size');
$this->assertEqual($message_total, $result['total'],
'total counts the whole folder, not just the window');
$this->assertTrue(
$counting_site->header_read_count <= $window,
'only the window of messages has its headers read, ' .
'not the whole folder');
}
/**
* fetchMessage round-trips the exact bytes appendMessage
* wrote: no normalisation, no CRLF mangling.
*/
public function fetchMessageRoundTripTestCase()
{
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$uid = (int) $messages[0]['UID'];
$bytes = $this->backend->fetchMessage('INBOX', $uid);
$this->assertEqual($this->sample_rfc822, $bytes,
'fetchMessage returns the exact bytes appendMessage' .
' wrote');
}
/**
* appendMessage to a fresh folder creates the folder
* (MailSite's allocator does this implicitly), allocates a
* fresh UID, and the next fetchMessage round-trips the
* bytes.
*/
public function appendMessageNewFolderTestCase()
{
$extra = "From: x@y\r\nSubject: extra\r\n\r\nbody\r\n";
$this->backend->appendMessage('Archive', $extra);
$messages = $this->backend->listMessages('Archive',
['window' => 25])['messages'];
$this->assertEqual(1, count($messages),
'Archive folder now has one message');
$uid = (int) $messages[0]['UID'];
$this->assertEqual($extra,
$this->backend->fetchMessage('Archive', $uid),
'Archive message round-trips');
}
/**
* setFlag toggles \Seen: starts unset (RamMailStorage
* default for a freshly-appended message), then sets, then
* clears. listMessages reports IS_READ accordingly.
*/
public function setFlagSeenToggleTestCase()
{
$uid = (int) $this->backend->listMessages('INBOX',
['window' => 25])['messages'][0]['UID'];
$this->backend->setFlag('INBOX', $uid, '\\Seen', true);
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertTrue(empty($messages[0]['IS_UNREAD']),
'IS_UNREAD false after setFlag \\Seen true');
$this->backend->setFlag('INBOX', $uid, '\\Seen', false);
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertTrue(!empty($messages[0]['IS_UNREAD']),
'IS_UNREAD true after setFlag \\Seen false');
}
/**
* moveMessage moves a UID from source to destination
* folder. The source loses one message, the destination
* gains one with the same bytes (the UID may differ since
* destination is an independent UID allocator).
*/
public function moveMessageTestCase()
{
$uid = (int) $this->backend->listMessages('INBOX',
['window' => 25])['messages'][0]['UID'];
$this->backend->moveMessage('INBOX', $uid, 'Archive');
$this->assertEqual(0,
count($this->backend->listMessages('INBOX',
['window' => 25])['messages']),
'INBOX is empty after the move');
$archive = $this->backend->listMessages('Archive',
['window' => 25])['messages'];
$this->assertEqual(1, count($archive),
'Archive has one message after the move');
$new_uid = (int) $archive[0]['UID'];
$this->assertEqual($this->sample_rfc822,
$this->backend->fetchMessage('Archive', $new_uid),
'Moved message bytes survived the move');
}
/**
* deleteMessage on a non-Trash folder is a soft-delete: it
* moves the message to Trash. INBOX empties; Trash gains
* the message.
*/
public function deleteMessageSoftTestCase()
{
$uid = (int) $this->backend->listMessages('INBOX',
['window' => 25])['messages'][0]['UID'];
$this->backend->deleteMessage('INBOX', $uid);
$this->assertEqual(0,
count($this->backend->listMessages('INBOX',
['window' => 25])['messages']),
'INBOX is empty after soft-delete');
$trash = $this->backend->listMessages('Trash',
['window' => 25])['messages'];
$this->assertEqual(1, count($trash),
'Trash has the deleted message');
}
/**
* deleteMessage on a Trash-source message is the
* permanent-delete semantic: the message disappears
* entirely.
*/
public function deleteMessageFromTrashTestCase()
{
$uid = (int) $this->backend->listMessages('INBOX',
['window' => 25])['messages'][0]['UID'];
$this->backend->deleteMessage('INBOX', $uid);
$trash_uid = (int) $this->backend->listMessages('Trash',
['window' => 25])['messages'][0]['UID'];
$this->backend->deleteMessage('Trash', $trash_uid);
$this->assertEqual(0,
count($this->backend->listMessages('Trash',
['window' => 25])['messages']),
'Trash is empty after permanent-delete');
}
/**
* setFlagBulk applies the same flag operation across a list
* of UIDs in one call. Append two extra messages, then
* setFlagBulk \Seen=true across all three, and verify each
* is read.
*/
public function setFlagBulkTestCase()
{
$this->backend->appendMessage('INBOX',
"From: a@x\r\nSubject: msg2\r\n\r\nbody2\r\n");
$this->backend->appendMessage('INBOX',
"From: b@x\r\nSubject: msg3\r\n\r\nbody3\r\n");
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertEqual(3, count($messages),
'INBOX has three messages before bulk flag');
$uids = [];
foreach ($messages as $msg) {
$uids[] = (int) $msg['UID'];
}
$this->backend->setFlagBulk('INBOX', $uids, '\\Seen',
true);
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
foreach ($messages as $msg) {
$this->assertTrue(empty($msg['IS_UNREAD']),
'UID ' . $msg['UID'] .
' not unread after bulk setFlag \\Seen');
}
}
/**
* deleteMessagesBulk on multiple INBOX UIDs soft-deletes
* all of them: INBOX empties, Trash gains all three.
*/
public function deleteMessagesBulkTestCase()
{
$this->backend->appendMessage('INBOX',
"From: a@x\r\nSubject: msg2\r\n\r\nbody2\r\n");
$this->backend->appendMessage('INBOX',
"From: b@x\r\nSubject: msg3\r\n\r\nbody3\r\n");
$uids = [];
foreach ($this->backend->listMessages('INBOX',
['window' => 25])['messages']
as $msg) {
$uids[] = (int) $msg['UID'];
}
$this->backend->deleteMessagesBulk('INBOX', $uids);
$this->assertEqual(0,
count($this->backend->listMessages('INBOX',
['window' => 25])['messages']),
'INBOX is empty after bulk delete');
$this->assertEqual(3,
count($this->backend->listMessages('Trash',
['window' => 25])['messages']),
'Trash has all three after bulk delete');
}
/**
* purgeMessagesBulk permanently removes a UID list from a
* non-Trash folder without first relocating to Trash: INBOX
* loses the purged messages and, unlike deleteMessagesBulk,
* no copy lands in Trash. This is the primitive the
* cross-account move uses to clear the source side.
*/
public function purgeMessagesBulkTestCase()
{
$this->backend->appendMessage('INBOX',
"From: a@x\r\nSubject: msg2\r\n\r\nbody2\r\n");
$this->backend->appendMessage('INBOX',
"From: b@x\r\nSubject: msg3\r\n\r\nbody3\r\n");
$messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertEqual(3, count($messages),
'INBOX has three messages before purge');
$purge = [];
foreach ($messages as $index => $msg) {
if ($index < 2) {
$purge[] = (int) $msg['UID'];
}
}
$this->backend->purgeMessagesBulk('INBOX', $purge);
$this->assertEqual(1,
count($this->backend->listMessages('INBOX',
['window' => 25])['messages']),
'INBOX keeps the one unpurged message');
$this->assertEqual(0,
count($this->backend->listMessages('Trash',
['window' => 25])['messages']),
'purge leaves no Trash copy behind');
}
/**
* A cross-account move copies each selected message into a
* second account's folder and then purges the originals from
* the source. Modeled here with two MailSiteMailBackend
* instances over the shared test MailSite, differing only by
* username (the two accounts), exercising the same
* fetch -> append -> purge sequence the controller's
* mailMoveAcrossAccounts helper performs: destination gains
* the messages with intact bytes, source loses exactly them
* and keeps the rest, and no Trash copy is left on the
* source.
*/
public function crossAccountMoveTestCase()
{
$destination = new MailSiteMailBackend('other', null);
$this->backend->appendMessage('INBOX',
"From: a@x\r\nSubject: msg2\r\n\r\nbody2\r\n");
$this->backend->appendMessage('INBOX',
"From: b@x\r\nSubject: msg3\r\n\r\nbody3\r\n");
$source_messages = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertEqual(3, count($source_messages),
'source INBOX has three messages before move');
$move = [];
$keep_uid = 0;
foreach ($source_messages as $index => $msg) {
if ($index < 2) {
$move[] = (int) $msg['UID'];
} else {
$keep_uid = (int) $msg['UID'];
}
}
$copied = [];
foreach ($move as $uid) {
$bytes = $this->backend->fetchMessage('INBOX', $uid);
$destination->appendMessage('Archive', $bytes);
$copied[] = $uid;
}
$this->backend->purgeMessagesBulk('INBOX', $copied);
$this->assertEqual(2,
count($destination->listMessages('Archive',
['window' => 25])['messages']),
'destination Archive gains the two moved messages');
$remaining = $this->backend->listMessages('INBOX',
['window' => 25])['messages'];
$this->assertEqual(1, count($remaining),
'source INBOX keeps the one unmoved message');
$this->assertEqual($keep_uid, (int) $remaining[0]['UID'],
'the message left in source is the unmoved one');
$this->assertEqual(0,
count($this->backend->listMessages('Trash',
['window' => 25])['messages']),
'cross-account move leaves no Trash copy on source');
$archived = $destination->listMessages('Archive',
['window' => 25])['messages'];
$archived_uid = (int) $archived[0]['UID'];
$body = $destination->fetchMessage('Archive',
$archived_uid);
$this->assertTrue(stripos($body, 'body') !== false,
'moved message body survived into destination');
}
}