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