/ tests / MailSiteMailBackendTest.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\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');
    }
}
X