<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\mail\MailSiteFactory;
/**
* Gathers the tests for MailSiteFactory: the rule that decides whether
* a member account may use a given email address, and the outbound
* spool state machine that relays authenticated local mail to remote
* recipients. The address rule is a plain function of its arguments, so
* those cases need no set-up; the outbound-spool cases build a temporary
* spool directory through their own helper first.
*
* @author Chris Pollett
*/
class MailSiteFactoryTest extends UnitTest
{
/**
* Probe exposing the spool with a scripted delivery outcome, built
* by the outbound-spool cases.
* @var ProbeMailSiteFactoryForOutbound
*/
public $probe;
/**
* Absolute path of the temporary spool directory an outbound-spool
* case uses, empty when none is outstanding.
* @var string
*/
public $spool = "";
/**
* Required by the test runner. The address-rule cases are plain
* functions of their arguments and the outbound-spool cases build
* their own spool through a helper, so there is nothing to do here.
*/
public function setUp()
{
}
/**
* Builds a fresh temporary spool directory and a probe bound to it,
* called at the start of each outbound-spool case.
*/
public function setUpOutboundSpool()
{
$this->spool = sys_get_temp_dir() . "/yioop_outbound_" .
uniqid("", true);
ProbeMailSiteFactoryForOutbound::$spool_override =
$this->spool;
ProbeMailSiteFactoryForOutbound::$delivery_result =
['ok' => true, 'error' => ''];
ProbeMailSiteFactoryForOutbound::$bounces = [];
$this->probe = new ProbeMailSiteFactoryForOutbound();
}
/**
* Removes the temporary spool directory an outbound-spool case made;
* the address-rule cases leave nothing on disk.
*/
public function tearDown()
{
if (is_dir($this->spool)) {
foreach (glob($this->spool . "/*") as $path) {
@unlink($path);
}
@rmdir($this->spool);
}
}
/**
* The bot's own address on a managed domain is always refused, even
* when same-domain accounts are otherwise allowed.
*/
public function botAddressAlwaysRefusedTestCase()
{
$this->assertTrue(MailSiteFactory::accountEmailRefused(
"bot@example.com", "bot", ["example.com"], false),
"bot address on a managed domain is refused");
}
/**
* An ordinary person on a managed domain is allowed by default, so
* an in-house install where everyone shares the domain still works.
*/
public function sameDomainAllowedByDefaultTestCase()
{
$this->assertFalse(MailSiteFactory::accountEmailRefused(
"chris@example.com", "bot", ["example.com"], false),
"non-bot same-domain address is allowed when not strict");
}
/**
* Turning on the strict flag refuses every address on a managed
* domain, not just the bot's.
*/
public function sameDomainRefusedWhenStrictTestCase()
{
$this->assertTrue(MailSiteFactory::accountEmailRefused(
"chris@example.com", "bot", ["example.com"], true),
"non-bot same-domain address is refused when strict");
}
/**
* An address on a domain the site does not handle is allowed even
* under the strict flag.
*/
public function otherDomainAllowedTestCase()
{
$this->assertFalse(MailSiteFactory::accountEmailRefused(
"chris@other.com", "bot", ["example.com"], true),
"address off the managed domains is allowed");
}
/**
* The bot is refused at any of several managed domains, not only the
* first one.
*/
public function botRefusedAtAnyManagedDomainTestCase()
{
$this->assertTrue(MailSiteFactory::accountEmailRefused(
"bot@two.com", "bot", ["one.com", "two.com"], false),
"bot address is refused at any managed domain");
}
/**
* The localhost placeholder that a site without mail domains
* reports is never treated as managed, so on such a site no address
* is refused.
*/
public function localhostNeverManagedTestCase()
{
$this->assertFalse(MailSiteFactory::accountEmailRefused(
"bot@localhost", "bot", ["localhost"], true),
"localhost is not a managed domain");
}
/**
* Both the mailbox name and the domain are compared without regard
* to letter case.
*/
public function caseInsensitiveTestCase()
{
$this->assertTrue(MailSiteFactory::accountEmailRefused(
"BOT@EXAMPLE.COM", "bot", ["Example.com"], false),
"comparison ignores letter case");
}
/**
* A string with no at-sign is not an address and is never refused.
*/
public function malformedAddressAllowedTestCase()
{
$this->assertFalse(MailSiteFactory::accountEmailRefused(
"not-an-address", "bot", ["example.com"], true),
"a string without an at-sign is not refused");
}
/**
* Queues all the spool files for one message and counts them.
* @return int number of envelope files in the spool
*/
protected function envelopeCount()
{
if (!is_dir($this->spool)) {
return 0;
}
return count(glob($this->spool . "/*.envelope"));
}
/**
* The onOutbound hook writes a matching eml and envelope pair
* carrying the sender, recipients, and a zero attempt count.
*/
public function enqueueWritesSpoolPairTestCase()
{
$this->setUpOutboundSpool();
$this->probe->enqueue(
['from' => 'chris@pollett.org',
'recipients' => ['carol@sjsu.edu'],
'bytes' => "Subject: hi\r\n\r\nbody"], []);
$this->assertEqual(1, $this->envelopeCount(),
"one envelope queued");
$emls = glob($this->spool . "/*.eml");
$this->assertEqual(1, count($emls),
"one message body queued");
$envelopes = glob($this->spool . "/*.envelope");
$record = json_decode(
file_get_contents($envelopes[0]), true);
$this->assertEqual('chris@pollett.org', $record['from'],
"sender recorded");
$this->assertEqual(0, $record['attempts'],
"attempt counter starts at zero");
}
/**
* A successful drain delivers the message and removes both of
* its spool files.
*/
public function successfulDrainClearsSpoolTestCase()
{
$this->setUpOutboundSpool();
$this->probe->enqueue(
['from' => 'chris@pollett.org',
'recipients' => ['carol@sjsu.edu'],
'bytes' => "Subject: hi\r\n\r\nbody"], []);
ProbeMailSiteFactoryForOutbound::$delivery_result =
['ok' => true, 'error' => ''];
$stats = $this->probe->drain();
$this->assertEqual(1, $stats['sent'], "one message sent");
$this->assertEqual(0, $this->envelopeCount(),
"spool cleared after successful delivery");
}
/**
* A transient failure leaves the message queued, increments
* its attempt counter, and schedules a later retry.
*/
public function transientFailureRetriesTestCase()
{
$this->setUpOutboundSpool();
$this->probe->enqueue(
['from' => 'chris@pollett.org',
'recipients' => ['carol@sjsu.edu'],
'bytes' => "Subject: hi\r\n\r\nbody"], []);
ProbeMailSiteFactoryForOutbound::$delivery_result =
['ok' => false, 'error' => 'could not connect'];
$stats = $this->probe->drain();
$this->assertEqual(1, $stats['retried'],
"message retried, not sent or bounced");
$this->assertEqual(1, $this->envelopeCount(),
"message still queued after transient failure");
$envelopes = glob($this->spool . "/*.envelope");
$record = json_decode(
file_get_contents($envelopes[0]), true);
$this->assertEqual(1, $record['attempts'],
"attempt counter incremented");
$this->assertTrue($record['next_attempt'] > time(),
"retry scheduled in the future");
}
/**
* Once the attempt cap is reached the message is bounced to its
* local sender and removed from the spool.
*/
public function permanentFailureBouncesTestCase()
{
$this->setUpOutboundSpool();
$this->probe->enqueue(
['from' => 'chris@pollett.org',
'recipients' => ['carol@sjsu.edu'],
'bytes' => "Subject: hi\r\n\r\nbody"], []);
ProbeMailSiteFactoryForOutbound::$delivery_result =
['ok' => false, 'error' => 'mailbox unavailable'];
$envelopes = glob($this->spool . "/*.envelope");
$record = json_decode(
file_get_contents($envelopes[0]), true);
$record['attempts'] = 2;
file_put_contents($envelopes[0], json_encode($record));
$stats = $this->probe->drain();
$this->assertEqual(1, $stats['bounced'],
"message bounced at the attempt cap");
$this->assertEqual(0, $this->envelopeCount(),
"spool cleared after a bounce");
$bounces = ProbeMailSiteFactoryForOutbound::$bounces;
$this->assertEqual(1, count($bounces),
"one bounce delivered");
$this->assertEqual('chris@pollett.org', $bounces[0]['to'],
"bounce returned to the local sender");
}
}
/**
* MailSiteFactory subclass that redirects the spool to a temporary
* directory, scripts the direct-MX delivery outcome, and captures
* bounces instead of posting them to a real mailbox, so the spool
* state machine can be tested without the network or a live store.
*
* @author Chris Pollett chris@pollett.org
*/
class ProbeMailSiteFactoryForOutbound extends MailSiteFactory
{
/**
* Temporary spool directory used in place of MAIL_DIR/outbound.
* @var string
*/
public static $spool_override = "";
/**
* Scripted result returned by the overridden delivery step.
* @var array
*/
public static $delivery_result = ['ok' => true, 'error' => ''];
/**
* Captured bounce notices: each entry has 'to' and 'error'.
* @var array
*/
public static $bounces = [];
/**
* Returns the temporary spool directory for tests.
* @return string spool directory path
*/
public static function outboundSpoolDir()
{
return self::$spool_override;
}
/**
* Calls the onOutbound enqueue path under test.
* @param array $info outbound details (from, recipients, bytes)
* @param array $context unused session context
*/
public function enqueue($info, $context)
{
self::enqueueOutbound($info, $context);
}
/**
* Runs the drain under test.
* @param int|null $now current time override
* @return array drain stats
*/
public function drain($now = null)
{
return self::drainOutbound($now);
}
/**
* Returns the scripted delivery result instead of doing real
* MX delivery.
* @param array $envelope decoded envelope record
* @param string $eml_path path to message bytes
* @return array delivery outcome
*/
protected static function deliverOutboundFile($envelope,
$eml_path)
{
return self::$delivery_result;
}
/**
* Captures the bounce target and reason rather than building a
* MailSite and posting to a mailbox.
* @param array $envelope decoded envelope record
* @param string $eml_path path to message bytes
* @param string $error final delivery error
* @return void
*/
protected static function bounceOutbound($envelope, $eml_path,
$error)
{
self::$bounces[] = ['to' => $envelope['from'] ?? '',
'error' => $error];
}
}