/ tests / MailSiteFactoryTest.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\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];
    }
}
X