/ tests / MailSiteTest.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\FileMailStorage;
use seekquarry\atto\RamMailStorage;
use seekquarry\yioop\library\UnitTest;

/**
 * Drives the daemon's command-fiber frame with a stub command that
 * yields once, standing in for a store-lock wait.
 */
class CommandFiberProbeMailSite extends MailSite
{
    /** @var int how many times the stub command ran */
    public $process_calls = 0;
    /**
     * Stub command: yields on its first run (as a contended store lock
     * would), then reports no more input.
     * @param int $key connection key
     * @return bool true on the first run so the caller loops
     */
    protected function processOne($key)
    {
        $this->process_calls++;
        if ($this->process_calls === 1) {
            \Fiber::suspend();
            return true;
        }
        return false;
    }
    /**
     * Registers a minimal connection context so the frame treats the
     * key as a live connection.
     * @param int $key connection key
     * @return void
     */
    public function registerContext($key)
    {
        $this->in_streams[self::CONTEXT][$key] = ['PROTOCOL' => 'IMAP'];
    }
    /**
     * @param int $key connection key
     * @return void
     */
    public function drive($key)
    {
        $this->driveConnectionFiber($key);
    }
    /**
     * @return void
     */
    public function resumeAll()
    {
        $this->resumePendingFibers();
    }
    /**
     * @return int number of parked connection fibers
     */
    public function pendingCount()
    {
        return count($this->pending_fibers);
    }
}

/**
 * Exposes FileMailStorage's protected cooperative lock helper so a test
 * can drive it directly, without standing up a whole mail store.
 */
class CooperativeFlockProbe extends FileMailStorage
{
    /**
     * Public wrapper around the protected cooperativeFlock().
     *
     * @param resource $handle open file handle to lock
     * @param int $operation the lock to take, LOCK_SH or LOCK_EX
     * @return bool true once the lock is held
     */
    public static function probe($handle, $operation)
    {
        return self::cooperativeFlock($handle, $operation);
    }
}

/**
 * Exposes FileMailStorage's bounded folder-index cache so a test can
 * drive its store-and-evict and most-recently-used promotion entirely
 * in memory, without standing up a real mail store on disk.
 */
class FolderIndexCacheProbe extends FileMailStorage
{
    /**
     * Caches an empty index under a key, exercising the bounded
     * insert that drops the least recently used folder when full.
     *
     * @param string $cache_key the cache key to store under
     */
    public function store($cache_key)
    {
        $this->storeFolderIndex($cache_key, [], 0);
    }
    /**
     * Reads a cached index back, which promotes its folder to most
     * recently used.
     *
     * @param string $cache_key the cache key to read
     * @return array|null the cached entry, or null on a miss
     */
    public function fetch($cache_key)
    {
        return $this->cachedFolderIndex($cache_key);
    }
}

/**
 * MailSite subclass that captures queueWrite output into a string
 * (or discards it when measuring memory) so the protected FETCH
 * emitter can be exercised without a real client socket.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForFetch extends MailSite
{
    /**
     * Concatenated bytes passed to queueWrite during a call.
     * @var string
     */
    public $taken = "";
    /**
     * When true, queued bytes are counted but not retained, so a
     * memory measurement reflects only what the emitter itself
     * holds rather than the captured transcript.
     * @var bool
     */
    public $drop_taken = false;
    /**
     * Runs the protected FETCH emitter.
     * @param int $sequence_number message sequence number
     * @param array $meta message metadata (uid, flags, size,
     *      internal_date)
     * @param string $body raw message bytes
     * @param string $items the FETCH items list
     * @param bool $is_uid_variant whether this is UID FETCH
     * @param bool $mark_seen set true when a non-peek body is read
     */
    public function emit($sequence_number, $meta, $body, $items,
        $is_uid_variant, &$mark_seen)
    {
        $this->taken = "";
        $this->imapEmitFetch(0, $sequence_number, $meta, $body,
            $items, $is_uid_variant, $mark_seen);
    }
    /**
     * Captures queued bytes instead of writing to a socket.
     * @param int $key connection key (ignored)
     * @param string $bytes bytes that would be sent to the client
     * @return void
     */
    protected function queueWrite($key, $bytes)
    {
        if (!$this->drop_taken) {
            $this->taken .= $bytes;
        }
    }
}

/**
 * MailSite subclass that exposes the protected handshake state
 * machinery and supplies test sockets and a throwaway certificate.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForHandshake extends MailSite
{
    /**
     * Initializes the stream tables and server globals the
     * handshake methods read, the way listen() would.
     */
    public function __construct()
    {
        $this->default_server_globals = [
            'TLS_HANDSHAKE_TIMEOUT' => 5,
            'CONNECTION_TIMEOUT' => 1800,
        ];
        $this->in_streams = [self::CONNECTION => [], self::DATA => [],
            self::CONTEXT => [], self::MODIFIED_TIME => []];
        $this->out_streams = [self::CONNECTION => [], self::DATA => [],
            self::CONTEXT => [], self::MODIFIED_TIME => []];
    }
    /**
     * Returns a connected pair of non-blocking sockets for tests.
     * @return array two stream resources
     */
    public function makePair()
    {
        $pair = stream_socket_pair(STREAM_PF_UNIX,
            STREAM_SOCK_STREAM, 0);
        stream_set_blocking($pair[0], 0);
        stream_set_blocking($pair[1], 0);
        return $pair;
    }
    /**
     * Starts a handshake through the protected beginHandshake.
     * @param resource $connection socket resource
     * @param int $key stream key
     * @param string $mode handshake mode
     * @param string $protocol protocol name
     * @param string $addr remote address
     * @param int $port remote port
     */
    public function begin($connection, $key, $mode, $protocol,
        $addr, $port)
    {
        $this->beginHandshake($connection, $key, $mode, $protocol,
            $addr, $port, null);
    }
    /**
     * Seeds a pending handshake entry directly, as if a step had
     * returned "needs more I/O", so the cull logic can be tested
     * without a live TLS peer (a socketpair cannot complete a real
     * handshake, and a raw TCP socket's "needs I/O" state is not
     * deterministic in a unit test).
     * @param resource $connection socket resource
     * @param int $key stream key
     * @param float $deadline absolute microtime deadline
     */
    public function seedPending($connection, $key, $deadline)
    {
        $this->in_streams[self::CONNECTION][$key] = $connection;
        $this->in_streams[self::MODIFIED_TIME][$key] = time();
        $this->handshakes[$key] = [
            'mode' => 'implicit',
            'protocol' => 'SMTP',
            'remote_addr' => '198.51.100.7',
            'remote_port' => 12345,
            'listener' => null,
            'deadline' => $deadline,
        ];
    }
    /**
     * Returns the pending-handshakes table for assertions.
     * @return array the handshakes table
     */
    public function pendingHandshakes()
    {
        return $this->handshakes;
    }
    /**
     * Runs the cull pass that evicts stalled handshakes.
     */
    public function cull()
    {
        $this->cullDeadStreams();
    }
}

/**
 * Minimal MailSite subclass that exposes the protected MIME entity
 * parser so the bounds can be tested directly.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForMime extends MailSite
{
    /**
     * Parses raw entity bytes through the protected parser.
     * @param string $bytes the entity bytes to parse
     * @return array the parsed MIME entity
     */
    public function parse($bytes)
    {
        return $this->imapParseEntity($bytes);
    }
}

/**
 * MailSite subclass that drives the protected SMTP DATA-phase consumer
 * with a caller-owned buffer, and records whether the connection was
 * dropped and what was written back, so the message-size cap can be
 * checked without sockets.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForSmtpData extends MailSite
{
    /** @var bool whether the consumer dropped the connection */
    public $was_shut_down = false;
    /** @var string bytes the consumer queued back to the client */
    public $written = "";
    /**
     * Sets just the message-size limit the DATA consumer reads.
     * @param int $max_message_len byte ceiling for one message
     */
    public function __construct($max_message_len)
    {
        $this->default_server_globals = [
            'MAX_MESSAGE_LEN' => $max_message_len,
        ];
    }
    /**
     * Records a drop rather than closing a real socket.
     * @param int $key connection key
     * @return void
     */
    protected function shutdownStream($key)
    {
        $this->was_shut_down = true;
    }
    /**
     * Captures bytes bound for the client rather than queueing them
     * on an out stream.
     * @param int $key connection key
     * @param string $bytes bytes to send
     * @return void
     */
    protected function queueWrite($key, $bytes)
    {
        $this->written .= $bytes;
    }
    /**
     * Runs the protected DATA consumer once and returns its result and
     * the buffer left after it ran.
     * @param int $key connection key
     * @param string $buffer bytes received so far in the DATA phase
     * @param array $context per-session context
     * @return array [bool consumed, string leftover buffer]
     */
    public function feedData($key, $buffer, $context)
    {
        $result = $this->consumeSmtpDataPhase($key, $buffer, $context);
        return [$result, $buffer];
    }
}

/**
 * Gathers the tests for the atto MailSite server in one place: its
 * command fibers, cooperative file locking, fetch memory use, the TLS
 * handshake reaper, MIME parsing, standard-folder provisioning, and
 * UID-restricted expunge. Each group sets up only what it needs through
 * its own helper, called at the start of its cases, since the server is
 * self-contained and the cases run against in-memory or temporary
 * storage with no network.
 *
 * @author Chris Pollett
 */
class MailSiteTest extends UnitTest
{
    /**
     * The probe mail server a group's cases drive by hand.
     * @var object
     */
    public $probe;
    /**
     * Path of the temporary lock file a cooperative-flock case makes,
     * empty when no such file is outstanding.
     * @var string
     */
    public $path = "";
    /**
     * In-memory mail storage the folder and expunge cases work against.
     * @var object
     */
    public $storage;
    /**
     * The mail server the folder and expunge cases work against.
     * @var object
     */
    public $mail_site;
    /**
     * Name of the mailbox account the folder and expunge cases use.
     * @var string
     */
    public $account;
    /**
     * Uid of the first seeded message in the expunge cases.
     * @var int
     */
    public $first_uid;
    /**
     * Uid of the second seeded message in the expunge cases.
     * @var int
     */
    public $second_uid;
    /**
     * Uid of the third seeded message in the expunge cases.
     * @var int
     */
    public $third_uid;
    /**
     * Required by the test runner. Each group sets up its own
     * state through its helper at the start of its cases, so
     * there is nothing to do here.
     */
    public function setUp()
    {
    }
    /**
     * Builds the state the CommandFiber cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe.
     */
    public function setUpCommandFiber()
    {
        $this->probe = new CommandFiberProbeMailSite();
    }
    /**
     * Builds the state the Flock cases need, called
     * at the start of each of those cases.
     */
    /**
     * Makes a throwaway file for the lock cases.
     */
    public function setUpFlock()
    {
        $this->path = tempnam(sys_get_temp_dir(), "msf");
    }
    /**
     * Builds the state the Fetch cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe instance used by every case.
     */
    public function setUpFetch()
    {
        $this->probe = new ProbeMailSiteForFetch();
    }
    /**
     * Builds the state the Handshake cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe used by every case.
     */
    public function setUpHandshake()
    {
        $this->probe = new ProbeMailSiteForHandshake();
    }
    /**
     * Builds the state the Mime cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe instance used by every case.
     */
    public function setUpMime()
    {
        $this->probe = new ProbeMailSiteForMime();
    }
    /**
     * Builds the state the Folders cases need, called
     * at the start of each of those cases.
     */
    /**
     * Provisions a fresh account with the standard folders.
     */
    public function setUpFolders()
    {
        $this->account = "bob";
        $this->mail_site = new MailSite();
        $this->storage = new RamMailStorage();
        $this->storage->ensureUser($this->account);
        $this->storage->ensureStandardFolders($this->account);
    }
    /**
     * Builds the state the Expunge cases need, called
     * at the start of each of those cases.
     */
    /**
     * Seeds three INBOX messages, two of them flagged deleted.
     */
    public function setUpExpunge()
    {
        $this->account = "alice";
        $this->mail_site = new MailSite();
        $this->storage = new RamMailStorage();
        $this->mail_site->storage($this->storage);
        for ($number = 1; $number <= 3; $number++) {
            $this->storage->appendMessage($this->account, 'INBOX',
                "Subject: message $number\r\n\r\nbody $number\r\n");
        }
        $messages = $this->storage->listMessages($this->account,
            'INBOX');
        $this->first_uid = $messages[0]['uid'];
        $this->second_uid = $messages[1]['uid'];
        $this->third_uid = $messages[2]['uid'];
        $this->storage->setFlags($this->account, 'INBOX',
            $this->first_uid, [MailSite::FLAG_DELETED]);
        $this->storage->setFlags($this->account, 'INBOX',
            $this->second_uid, [MailSite::FLAG_DELETED]);
    }
    /**
     * Removes any temporary lock file a cooperative-flock case
     * created; the other groups leave nothing on disk to clean.
     */
    public function tearDown()
    {
        if ($this->path !== "" && is_file($this->path)) {
            unlink($this->path);
        }
    }
    /**
     * A command that yields parks its fiber; resuming runs it to the end
     * and unparks it.
     */
    public function commandFiberParksAndDrainsTestCase()
    {
        $this->setUpCommandFiber();
        $this->probe->registerContext(7);
        $this->probe->drive(7);
        $this->assertTrue($this->probe->pendingCount() === 1,
            "a command that yields parks its fiber");
        $this->assertTrue($this->probe->process_calls === 1,
            "the fiber suspended mid-command");
        $this->probe->resumeAll();
        $this->assertTrue($this->probe->pendingCount() === 0,
            "resuming finishes the command and unparks the fiber");
        $this->assertTrue($this->probe->process_calls === 2,
            "the command ran to completion once resumed");
    }
    /**
     * With another handle holding the file exclusively, a shared-lock
     * read started inside a fiber suspends instead of blocking; once the
     * exclusive lock is released and the fiber is resumed, the read
     * acquires its lock and finishes. This is what keeps one local-mailbox
     * read from freezing every other web connection while the mail daemon
     * is mid-write.
     */
    public function sharedReadYieldsWhileExclusiveHeldTestCase()
    {
        $this->setUpFlock();
        $blocker = fopen($this->path, "rb+");
        flock($blocker, LOCK_EX);
        $reader = fopen($this->path, "rb");
        $acquired = false;
        $fiber = new \Fiber(function () use ($reader, &$acquired) {
            CooperativeFlockProbe::probe($reader, LOCK_SH);
            $acquired = true;
        });
        $fiber->start();
        $this->assertTrue($fiber->isSuspended(),
            "the read suspends while the file is held exclusively");
        $this->assertTrue($acquired === false,
            "the lock has not been taken yet while suspended");
        flock($blocker, LOCK_UN);
        while ($fiber->isSuspended()) {
            $fiber->resume();
        }
        $this->assertTrue($acquired,
            "the read takes its lock once the exclusive lock frees");
        fclose($reader);
        fclose($blocker);
    }
    /**
     * Outside a fiber the helper is a plain blocking lock, so the mail
     * daemon and the command-line tools behave exactly as before; an
     * uncontended shared lock is taken at once.
     */
    public function uncontendedLockTakenDirectlyTestCase()
    {
        $this->setUpFlock();
        $reader = fopen($this->path, "rb");
        CooperativeFlockProbe::probe($reader, LOCK_SH);
        $this->assertTrue(flock($reader, LOCK_UN),
            "an uncontended shared lock is held and can be released");
        fclose($reader);
    }
    /**
     * A delivery's exclusive lock behaves the same way inside a fiber:
     * while another handle holds the file, it suspends; once that frees
     * and the fiber resumes, it takes the lock and reports success.
     */
    public function exclusiveWriteYieldsWhileHeldTestCase()
    {
        $this->setUpFlock();
        $blocker = fopen($this->path, "rb+");
        flock($blocker, LOCK_EX);
        $writer = fopen($this->path, "rb+");
        $result = null;
        $fiber = new \Fiber(function () use ($writer, &$result) {
            $result = CooperativeFlockProbe::probe($writer, LOCK_EX);
        });
        $fiber->start();
        $this->assertTrue($fiber->isSuspended(),
            "the write lock suspends while the file is held");
        flock($blocker, LOCK_UN);
        while ($fiber->isSuspended()) {
            $fiber->resume();
        }
        $this->assertTrue($result === true,
            "the write lock is taken and reports success once free");
        fclose($writer);
        fclose($blocker);
    }
    /**
     * A small FETCH of FLAGS and RFC822.SIZE produces the exact
     * untagged response line with both items inside one set of
     * parentheses.
     */
    public function smallFetchWireFormatTestCase()
    {
        $this->setUpFetch();
        $meta = ['uid' => 7, 'flags' => ['\\Seen'],
            'internal_date' => 0, 'size' => 5];
        $mark_seen = false;
        $this->probe->emit(3, $meta, "hello", "FLAGS RFC822.SIZE",
            false, $mark_seen);
        $output = $this->probe->taken;
        $this->assertEqual(
            "* 3 FETCH (FLAGS (\\Seen) RFC822.SIZE 5)\r\n", $output,
            "small fetch emits one correct untagged FETCH line");
    }
    /**
     * A BODY[] fetch returns the whole message as an IMAP literal
     * with the right byte count and trailing bytes.
     */
    public function bodyFetchEmitsLiteralTestCase()
    {
        $this->setUpFetch();
        $body = str_repeat("A", 1000);
        $meta = ['uid' => 1, 'flags' => [],
            'internal_date' => 0, 'size' => 1000];
        $mark_seen = false;
        $this->probe->emit(1, $meta, $body, "BODY[]", false,
            $mark_seen);
        $output = $this->probe->taken;
        $this->assertTrue(
            str_contains($output, "BODY[] {1000}\r\n"),
            "body literal announces the correct byte count");
        $this->assertTrue(str_ends_with($output, ")\r\n"),
            "response closes the FETCH parenthesis");
        $this->assertTrue($mark_seen,
            "non-peek BODY[] marks the message seen");
    }
    /**
     * Emitting a large body must not retain several copies of it.
     * The emitter queues pieces directly instead of building a
     * parts array and imploding, so peak memory growth across the
     * call should stay well under the multiple-copy cost of the
     * old path (which held three-plus copies). We allow up to two
     * body-sizes of headroom and assert we stay below three.
     */
    public function largeBodyDoesNotMultiplyCopiesTestCase()
    {
        $this->setUpFetch();
        $size = 8388608;
        $body = str_repeat("B", $size);
        $meta = ['uid' => 2, 'flags' => [],
            'internal_date' => 0, 'size' => $size];
        $mark_seen = false;
        $this->probe->drop_taken = true;
        $before = memory_get_usage();
        $this->probe->emit(2, $meta, $body, "BODY[]", false,
            $mark_seen);
        $after = memory_get_usage();
        $growth = $after - $before;
        $this->assertTrue($growth < 3 * $size,
            "emitting a large body holds fewer than three copies");
    }
    /**
     * Starting a handshake without a configured certificate drops
     * the socket: prepareCrypto fails, so nothing is left pending.
     */
    public function beginWithoutCertDropsTestCase()
    {
        $this->setUpHandshake();
        list($near, $far) = $this->probe->makePair();
        $key = (int) $near;
        $this->probe->begin($near, $key, 'implicit', 'SMTP',
            '198.51.100.7', 12345);
        $pending = $this->probe->pendingHandshakes();
        $this->assertTrue(!isset($pending[$key]),
            "no certificate means no pending handshake");
        is_resource($far) && fclose($far);
    }
    /**
     * A pending handshake whose deadline has passed is removed by
     * the cull pass, so a client that opens a socket and never
     * finishes the handshake cannot hold the slot or block.
     */
    public function stalledHandshakeIsEvictedTestCase()
    {
        $this->setUpHandshake();
        list($near, $far) = $this->probe->makePair();
        $key = (int) $near;
        $this->probe->seedPending($near, $key, microtime(true) - 1);
        $before = $this->probe->pendingHandshakes();
        $this->assertTrue(isset($before[$key]),
            "handshake is pending before the cull");
        $this->probe->cull();
        $after = $this->probe->pendingHandshakes();
        $this->assertTrue(!isset($after[$key]),
            "a handshake past its deadline is evicted");
        is_resource($far) && fclose($far);
    }
    /**
     * A pending handshake still within its deadline survives a cull
     * pass, so a handshake that is merely mid-negotiation is not
     * torn down prematurely.
     */
    public function freshHandshakeSurvivesCullTestCase()
    {
        $this->setUpHandshake();
        list($near, $far) = $this->probe->makePair();
        $key = (int) $near;
        $this->probe->seedPending($near, $key, microtime(true) + 30);
        $this->probe->cull();
        $after = $this->probe->pendingHandshakes();
        $this->assertTrue(isset($after[$key]),
            "a handshake within its deadline is kept");
        is_resource($near) && fclose($near);
        is_resource($far) && fclose($far);
    }
    /**
     * A multipart entity that declares an empty boundary must not
     * recurse: an empty boundary degenerates to the delimiter "--",
     * which would otherwise match throughout the body and split it
     * into a runaway number of parts.
     */
    public function emptyBoundaryProducesNoPartsTestCase()
    {
        $this->setUpMime();
        $bytes = "Content-Type: multipart/mixed; boundary=\"\"\r\n" .
            "\r\n" . str_repeat("X", 2000) .
            "\r\n--\r\nfiller\r\n--\r\n";
        $entity = $this->probe->parse($bytes);
        $this->assertEqual('multipart', $entity['type'],
            "empty boundary still parses as multipart at top");
        $this->assertEqual(0, count($entity['parts']),
            "empty boundary yields no child parts, no runaway");
    }
    /**
     * A message whose parts keep declaring themselves multipart
     * with the same boundary must stop at the depth bound rather
     * than recursing until memory is exhausted.
     */
    public function deeplyNestedMultipartIsDepthBoundedTestCase()
    {
        $this->setUpMime();
        $bytes = "Content-Type: multipart/mixed; boundary=b\r\n\r\n";
        $nested = "";
        for ($i = 0; $i < 200; $i++) {
            $nested .= "--b\r\nContent-Type: multipart/mixed; " .
                "boundary=b\r\n\r\n";
        }
        $bytes .= $nested . "--b--\r\n";
        $before = memory_get_usage();
        $entity = $this->probe->parse($bytes);
        $delta = memory_get_usage() - $before;
        $this->assertTrue(is_array($entity['parts']),
            "deeply nested message parses without exhausting memory");
        $this->assertTrue($delta < 16 * 1024 * 1024,
            "depth-bounded parse stays well under the memory limit");
    }
    /**
     * A normal two-part multipart/alternative message still parses
     * into its two child entities, so the bounds do not disturb
     * ordinary mail.
     */
    public function normalMultipartStillParsesTestCase()
    {
        $this->setUpMime();
        $bytes = "Content-Type: multipart/alternative; " .
            "boundary=xyz\r\n\r\n" .
            "--xyz\r\nContent-Type: text/plain\r\n\r\nHello\r\n" .
            "--xyz\r\nContent-Type: text/html\r\n\r\n" .
            "<p>Hi</p>\r\n--xyz--\r\n";
        $entity = $this->probe->parse($bytes);
        $this->assertEqual('multipart', $entity['type'],
            "normal message parses as multipart");
        $this->assertEqual('alternative', $entity['subtype'],
            "subtype preserved");
        $this->assertEqual(2, count($entity['parts']),
            "both child parts are parsed");
    }
    /**
     * A multipart body larger than the structure-parse cap is not
     * split into parts and its body string is not materialized, so
     * a message dominated by a large attachment cannot blow up
     * memory during a BODYSTRUCTURE fetch. The reported size still
     * reflects the real body length.
     */
    public function oversizedBodyIsNotSplitTestCase()
    {
        $this->setUpMime();
        $filler_length = MailSite::MAX_STRUCTURE_PARSE_BYTES + 1024;
        $bytes = "Content-Type: multipart/mixed; boundary=b\r\n\r\n" .
            "--b\r\nContent-Type: application/octet-stream\r\n\r\n" .
            str_repeat("A", $filler_length) . "\r\n--b--\r\n";
        $entity = $this->probe->parse($bytes);
        $this->assertEqual('multipart', $entity['type'],
            "oversized message still parses as multipart at top");
        $this->assertEqual(0, count($entity['parts']),
            "oversized body is left unsplit");
        $this->assertEqual('', $entity['body'],
            "oversized body string is not materialized");
        $this->assertTrue($entity['size'] >= $filler_length,
            "reported size still reflects the real body length");
    }
    /**
     * The four special-use folders plus INBOX exist after
     * provisioning.
     */
    public function standardFoldersProvisionedTestCase()
    {
        $this->setUpFolders();
        foreach ([MailSite::FOLDER_INBOX, MailSite::FOLDER_SENT,
            MailSite::FOLDER_DRAFTS, MailSite::FOLDER_JUNK,
            MailSite::FOLDER_TRASH] as $folder) {
            $this->assertTrue($this->storage->folderExists(
                $this->account, $folder),
                "$folder exists after provisioning");
        }
    }
    /**
     * Provisioning twice does not error or duplicate folders;
     * createFolder is idempotent, which matters because the
     * provisioning runs on every login.
     */
    public function provisioningIsIdempotentTestCase()
    {
        $this->setUpFolders();
        $before = $this->storage->listFolders($this->account);
        $this->storage->ensureStandardFolders($this->account);
        $after = $this->storage->listFolders($this->account);
        sort($before);
        sort($after);
        $this->assertEqual($before, $after,
            "re-provisioning leaves the folder set unchanged");
    }
    /**
     * An Apple-style delete (copy to Trash, flag the source
     * \Deleted, expunge) removes the message from INBOX and
     * leaves a copy in Trash.
     */
    public function copyToTrashThenExpungeDeletesTestCase()
    {
        $this->setUpFolders();
        $uid = $this->storage->appendMessage($this->account,
            'INBOX', "Subject: remove me\r\n\r\nbody\r\n");
        $body = $this->storage->fetchMessage($this->account,
            'INBOX', $uid);
        $this->storage->appendMessage($this->account,
            MailSite::FOLDER_TRASH, $body, [], 0);
        $this->storage->setFlags($this->account, 'INBOX', $uid,
            [MailSite::FLAG_DELETED]);
        $this->storage->expunge($this->account, 'INBOX');
        $this->assertEqual(0, $this->storage->messageCount(
            $this->account, 'INBOX'),
            "the deleted message left INBOX");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_TRASH),
            "a copy of the deleted message is in Trash");
    }
    /**
     * Deleting an existing non-INBOX folder succeeds, and a
     * folder that was never created reports as absent. These are
     * the storage primitives behind the protocol layer answering
     * OK to a DELETE of an already-gone folder (so a client can
     * clear a phantom folder from its cache).
     */
    public function deleteFolderAndPhantomTestCase()
    {
        $this->setUpFolders();
        $this->storage->createFolder($this->account, 'Archive');
        $this->assertTrue($this->storage->folderExists(
            $this->account, 'Archive'),
            "Archive exists before deletion");
        $this->assertTrue($this->storage->deleteFolder(
            $this->account, 'Archive'),
            "deleting an existing folder succeeds");
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Archive'),
            "Archive is gone after deletion");
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Deleted Messages'),
            "a folder that was never created reports as absent");
    }
    /**
     * A pre-existing non-canonical role folder (e.g. "Deleted
     * Messages" for Trash, "Sent Messages" for Sent) is
     * consolidated into the canonical folder: its messages move
     * over and the non-canonical folder is removed. Running the
     * consolidation again is a no-op.
     */
    public function consolidatesNonCanonicalFoldersTestCase()
    {
        $this->setUpFolders();
        $this->storage->createFolder($this->account,
            'Deleted Messages');
        $this->storage->appendMessage($this->account,
            'Deleted Messages', "Subject: old\r\n\r\nx\r\n");
        $this->storage->appendMessage($this->account,
            'Deleted Messages', "Subject: old two\r\n\r\ny\r\n");
        $this->storage->createFolder($this->account,
            'Sent Messages');
        $this->storage->appendMessage($this->account,
            'Sent Messages', "Subject: out\r\n\r\nz\r\n");
        $this->storage->ensureStandardFolders($this->account);
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Deleted Messages'),
            "non-canonical Trash folder was removed");
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Sent Messages'),
            "non-canonical Sent folder was removed");
        $this->assertEqual(2, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_TRASH),
            "Trash received both consolidated messages");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_SENT),
            "Sent received the consolidated message");
        $this->storage->ensureStandardFolders($this->account);
        $this->assertEqual(2, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_TRASH),
            "a second consolidation run does not duplicate mail");
    }
    /**
     * ensureStandardFolders returns a summary describing each
     * consolidation it performed, and returns an empty list when
     * an account is already clean.
     */
    public function consolidationSummaryReturnedTestCase()
    {
        $this->setUpFolders();
        $clean = $this->storage->ensureStandardFolders(
            $this->account);
        $this->assertEqual([], $clean,
            "a clean account reports no consolidations");
        $this->storage->createFolder($this->account,
            'Deleted Messages');
        $this->storage->appendMessage($this->account,
            'Deleted Messages', "Subject: a\r\n\r\nb\r\n");
        $summary = $this->storage->ensureStandardFolders(
            $this->account);
        $this->assertEqual(1, count($summary),
            "one consolidation is reported");
        $this->assertEqual('Deleted Messages',
            $summary[0]['from'],
            "summary names the removed folder");
        $this->assertEqual(MailSite::FOLDER_TRASH,
            $summary[0]['into'],
            "summary names the canonical target");
        $this->assertEqual(1, $summary[0]['moved'],
            "summary reports the moved message count");
    }
    /**
     * Multiple registered onLog callbacks each receive MailSite
     * log events in registration order, and emitLog is a no-op
     * when none are registered.
     */
    public function onLogHandlerReceivesEventsTestCase()
    {
        $this->setUpFolders();
        $reflection = new \ReflectionMethod($this->mail_site,
            'emitLog');
        $reflection->invoke($this->mail_site, "before any handler");
        $first = [];
        $second = [];
        $this->mail_site->onLog(function ($message) use (&$first) {
            $first[] = $message;
        });
        $this->mail_site->onLog(function ($message) use (&$second) {
            $second[] = $message;
        });
        $reflection->invoke($this->mail_site, "hello log");
        $this->assertEqual(["hello log"], $first,
            "the first registered handler received the message");
        $this->assertEqual(["hello log"], $second,
            "the second registered handler received the message");
    }
    /**
     * Folders with spaces and hierarchy are stored as literal,
     * nested directories: a space stays a space (no percent
     * encoding) and "Archive/2026" nests under "Archive". Listing
     * returns the slash-joined paths, messages are addressable at
     * each level, and a parent that still has children cannot be
     * deleted.
     */
    public function nestedAndSpacedFoldersTestCase()
    {
        $this->setUpFolders();
        $this->storage->createFolder($this->account,
            'Seekquarry Leads');
        $this->storage->createFolder($this->account, 'Archive/2026');
        $this->storage->appendMessage($this->account,
            'Seekquarry Leads', "Subject: lead\r\n\r\nx\r\n");
        $this->storage->appendMessage($this->account,
            'Archive/2026', "Subject: arc\r\n\r\ny\r\n");
        $folders = $this->storage->listFolders($this->account);
        $this->assertTrue(in_array('Seekquarry Leads', $folders,
            true), "the spaced folder is listed by its literal name");
        $this->assertTrue(in_array('Archive', $folders, true),
            "the intermediate parent folder is listed");
        $this->assertTrue(in_array('Archive/2026', $folders, true),
            "the nested child folder is listed by its path");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, 'Seekquarry Leads'),
            "messages are addressable in the spaced folder");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, 'Archive/2026'),
            "messages are addressable in the nested folder");
        $this->assertFalse($this->storage->deleteFolder(
            $this->account, 'Archive'),
            "a parent with a child folder cannot be deleted");
        $this->assertTrue($this->storage->deleteFolder(
            $this->account, 'Archive/2026'),
            "the leaf child folder can be deleted");
        $this->assertTrue($this->storage->deleteFolder(
            $this->account, 'Archive'),
            "the parent can be deleted once it has no children");
    }
    /**
     * When a message in a non-canonical folder cannot be copied
     * into its canonical folder, consolidation keeps the source
     * folder holding exactly the uncopied message (no data loss),
     * moves the rest, and a later retry that can read the message
     * finishes the move without duplicating the mail that already
     * landed in the canonical folder.
     */
    public function partialFailureKeepsSourceTestCase()
    {
        $this->setUpFolders();
        $storage = new class extends RamMailStorage {
            /**
             * uid whose fetchMessage returns false.
             * @var int
             */
            public $fail_uid = 0;
            /**
             * Refuses the designated uid; otherwise defers to the
             * in-memory parent.
             * @param string $user username (no @domain)
             * @param string $folder folder name with hierarchy
             * @param int $uid persistent IMAP unique identifier
             * @return string|false bytes, or false when designated
             */
            public function fetchMessage($user, $folder, $uid)
            {
                if ((int) $uid === $this->fail_uid) {
                    return false;
                }
                return parent::fetchMessage($user, $folder, $uid);
            }
        };
        $storage->ensureUser($this->account);
        $storage->ensureStandardFolders($this->account);
        $storage->createFolder($this->account, 'Sent Messages');
        $good = $storage->appendMessage($this->account,
            'Sent Messages', "Subject: copies\r\n\r\nx\r\n");
        $bad = $storage->appendMessage($this->account,
            'Sent Messages', "Subject: stuck\r\n\r\ny\r\n");
        $storage->fail_uid = $bad;
        $summary = $storage->ensureStandardFolders($this->account);
        $this->assertTrue($storage->folderExists($this->account,
            'Sent Messages'),
            "source folder is kept when a copy fails");
        $this->assertEqual(1, $storage->messageCount($this->account,
            'Sent Messages'),
            "only the uncopied message remains in the source");
        $this->assertEqual(1, $storage->messageCount($this->account,
            MailSite::FOLDER_SENT),
            "the copyable message reached the canonical folder");
        $entry = null;
        foreach ($summary as $consolidation) {
            if ($consolidation['from'] === 'Sent Messages') {
                $entry = $consolidation;
            }
        }
        $this->assertTrue($entry !== null &&
            $entry['failed'] === 1 && $entry['deleted'] === false,
            "summary reports the failure and that the source stayed");
        $storage->fail_uid = 0;
        $storage->ensureStandardFolders($this->account);
        $this->assertFalse($storage->folderExists($this->account,
            'Sent Messages'),
            "a retry that can read the message removes the source");
        $this->assertEqual(2, $storage->messageCount($this->account,
            MailSite::FOLDER_SENT),
            "the retry moves the last message without duplicating");
    }
    /**
     * A UID-restricted expunge removes only the deleted message
     * whose UID is in the restriction, leaving the other deleted
     * message in place.
     */
    public function uidRestrictedExpungeRemovesOnlyNamedTestCase()
    {
        $this->setUpExpunge();
        $removed = $this->storage->expunge($this->account, 'INBOX',
            [$this->first_uid]);
        $this->assertEqual([$this->first_uid], $removed,
            "only the restricted deleted UID is expunged");
        $remaining = [];
        foreach ($this->storage->listMessages($this->account,
            'INBOX') as $meta) {
            $remaining[] = $meta['uid'];
        }
        sort($remaining);
        $expected = [$this->second_uid, $this->third_uid];
        sort($expected);
        $this->assertEqual($expected, $remaining,
            "the other deleted message and the live message stay");
    }
    /**
     * A UID restriction naming a non-deleted message expunges
     * nothing, since only deleted messages are ever removed.
     */
    public function uidRestrictionOnLiveMessageRemovesNothingTestCase()
    {
        $this->setUpExpunge();
        $removed = $this->storage->expunge($this->account, 'INBOX',
            [$this->third_uid]);
        $this->assertEqual([], $removed,
            "a live message named in the set is not expunged");
        $this->assertEqual(3, count($this->storage->listMessages(
            $this->account, 'INBOX')),
            "no messages were removed");
    }
    /**
     * An unrestricted expunge still removes every deleted
     * message, preserving the plain EXPUNGE behavior desktop
     * clients rely on.
     */
    public function unrestrictedExpungeRemovesAllDeletedTestCase()
    {
        $this->setUpExpunge();
        $removed = $this->storage->expunge($this->account, 'INBOX');
        sort($removed);
        $expected = [$this->first_uid, $this->second_uid];
        sort($expected);
        $this->assertEqual($expected, $removed,
            "plain expunge removes all deleted messages");
        $this->assertEqual(1, count($this->storage->listMessages(
            $this->account, 'INBOX')),
            "only the one live message remains");
    }
    /**
     * Caching more folders than the limit allows drops the oldest and
     * holds the count at the limit, so a mail server that visits many
     * mailboxes over its life does not grow without bound.
     */
    public function folderIndexCacheEvictsOldestTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $limit = FileMailStorage::MAX_CACHED_FOLDER_INDEXES;
        for ($i = 0; $i < $limit; $i++) {
            $probe->store("folder" . $i);
        }
        $probe->store("overflow");
        $this->assertTrue($probe->fetch("folder0") === null,
            "the oldest folder is evicted once the limit is passed");
        $this->assertTrue($probe->fetch("overflow") !== null,
            "the newest folder is resident");
    }
    /**
     * Reading a folder marks it most recently used, so a later
     * overflow evicts a different, untouched folder rather than the
     * one still in active use.
     */
    public function folderIndexCachePromotesOnReadTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $limit = FileMailStorage::MAX_CACHED_FOLDER_INDEXES;
        for ($i = 0; $i < $limit; $i++) {
            $probe->store("folder" . $i);
        }
        $probe->fetch("folder0");
        $probe->store("overflow");
        $this->assertTrue($probe->fetch("folder0") !== null,
            "the touched folder survives the eviction");
        $this->assertTrue($probe->fetch("folder1") === null,
            "the next-oldest untouched folder is evicted instead");
    }
    /**
     * A DATA body that runs past the size limit without ever sending its
     * end-of-message marker drops the connection instead of buffering
     * without bound, the crash this cap prevents.
     */
    public function unterminatedOversizeDataDropsTestCase()
    {
        $probe = new ProbeMailSiteForSmtpData(1024);
        $buffer = str_repeat("x", 4096);
        [$result, $left] = $probe->feedData(7,
            $buffer, ['STATE' => 'DATA']);
        $this->assertFalse($result,
            "an unterminated over-limit body reports no command consumed");
        $this->assertTrue($probe->was_shut_down,
            "the connection is dropped rather than buffered without bound");
    }
    /**
     * A partial body still under the limit waits for more input and keeps
     * the connection, so the cap does not fire on ordinary streaming.
     */
    public function underLimitPartialDataWaitsTestCase()
    {
        $probe = new ProbeMailSiteForSmtpData(1024);
        $buffer = str_repeat("y", 200);
        [$result, $left] = $probe->feedData(7,
            $buffer, ['STATE' => 'DATA']);
        $this->assertFalse($result,
            "a short partial body reports no command consumed yet");
        $this->assertFalse($probe->was_shut_down,
            "an under-limit partial body keeps the connection open");
    }
    /**
     * An over-limit body that does arrive with its terminator still gets
     * the 552 reply and keeps the connection, the courteous path the cap
     * leaves intact.
     */
    public function terminatedOversizeDataGets552TestCase()
    {
        $probe = new ProbeMailSiteForSmtpData(1024);
        $buffer = str_repeat("z", 4096) . "\r\n.\r\n";
        [$result, $left] = $probe->feedData(7,
            $buffer, ['STATE' => 'DATA', 'MAILFROM' => 'a@b.c',
            'RCPTTO' => []]);
        $this->assertTrue($result,
            "a terminated over-limit body is consumed and answered");
        $this->assertTrue(strpos($probe->written, "552") !== false,
            "the client is told the message exceeds the size limit");
        $this->assertFalse($probe->was_shut_down,
            "the terminated over-limit path replies instead of dropping");
    }
}
X