/ 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;
use seekquarry\yioop\library\mail\SmtpClient;
use seekquarry\yioop\configs as C;

/**
 * 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");
    }
    /**
     * Each delivery posture maps to the expected policy mode: the
     * insecure posture publishes nothing, spam publishes a testing
     * policy, and require publishes an enforce policy.
     */
    public function modeMappingTestCase()
    {
        $this->assertEqual('', MailSiteFactory::mtaStsMode(
            'insecure'), "insecure posture publishes no policy");
        $this->assertEqual('testing', MailSiteFactory::mtaStsMode(
            'spam'), "spam posture publishes a testing policy");
        $this->assertEqual('enforce', MailSiteFactory::mtaStsMode(
            'require'), "require posture publishes enforce");
    }
    /**
     * The policy body carries the RFC 8461 fields: the STSv1
     * version, the mode, both a wildcard and an apex mx line for
     * the domain, and a max_age, with CRLF line endings.
     */
    public function policyBodyTestCase()
    {
        $policy = MailSiteFactory::mtaStsPolicy('testing',
            'yioop.com');
        $this->assertTrue(strpos($policy, "version: STSv1") === 0,
            "policy starts with the STSv1 version line");
        $this->assertTrue(strpos($policy, "mode: testing") !== false,
            "policy carries the mode");
        $this->assertTrue(strpos($policy, "mx: *.yioop.com") !== false,
            "policy authorizes subdomain MX hosts");
        $this->assertTrue(strpos($policy, "mx: yioop.com") !== false,
            "policy authorizes an apex MX host");
        $this->assertTrue(strpos($policy, "max_age: ") !== false,
            "policy carries a max_age");
        $this->assertTrue(strpos($policy, "\r\n") !== false,
            "policy uses CRLF line endings");
    }
    /**
     * An empty mode yields an empty policy body, so the route can
     * treat it as nothing to serve.
     */
    public function emptyModeYieldsNoPolicyTestCase()
    {
        $this->assertEqual('', MailSiteFactory::mtaStsPolicy('',
            'yioop.com'), "no mode means no policy body");
    }
    /**
     * The dedicated mta-sts.<domain> host resolves to that
     * configured domain, matched case-insensitively, while a host
     * for an unconfigured domain resolves to nothing.
     */
    public function hostResolvesConfiguredDomainTestCase()
    {
        $domains = ['yioop.com', 'example.org'];
        $this->assertEqual('yioop.com',
            MailSiteFactory::mtaStsDomainForHost(
            'mta-sts.yioop.com', $domains),
            "mta-sts host resolves to its domain");
        $this->assertEqual('example.org',
            MailSiteFactory::mtaStsDomainForHost(
            'MTA-STS.Example.Org', $domains),
            "host match is case-insensitive");
        $this->assertEqual('',
            MailSiteFactory::mtaStsDomainForHost(
            'mta-sts.notmine.com', $domains),
            "unconfigured domain resolves to nothing");
    }
    /**
     * A bare configured domain (without the mta-sts prefix) does
     * not resolve, so the apex site is not mistaken for the policy
     * host.
     */
    public function bareDomainDoesNotResolveTestCase()
    {
        $domains = ['yioop.com'];
        $this->assertEqual('',
            MailSiteFactory::mtaStsDomainForHost('yioop.com',
            $domains), "bare domain is not the policy host");
    }
    /**
     * Loopback hosts resolve to the first configured domain as a
     * testing convenience, and a port suffix on the host is
     * ignored.
     */
    public function loopbackTestPathTestCase()
    {
        $domains = ['yioop.com', 'example.org'];
        $this->assertEqual('yioop.com',
            MailSiteFactory::mtaStsDomainForHost('localhost',
            $domains), "localhost resolves to the first domain");
        $this->assertEqual('yioop.com',
            MailSiteFactory::mtaStsDomainForHost('127.0.0.1:8080',
            $domains), "a port suffix is ignored");
    }
    /**
     * The _mta-sts discovery record carries the STSv1 version and
     * the given id when a policy is published, and is empty when
     * the posture publishes no policy.
     */
    public function mtaStsTxtRecordTestCase()
    {
        $record = MailSiteFactory::mtaStsTxtRecord('testing', 42);
        $this->assertTrue(strpos($record, 'v=STSv1') === 0,
            "discovery record starts with the version tag");
        $this->assertTrue(strpos($record, 'id=42') !== false,
            "discovery record carries the policy id");
        $this->assertEqual('',
            MailSiteFactory::mtaStsTxtRecord('', 42),
            "no discovery record when no policy is published");
    }
    /**
     * The assembled suggestions include SPF and DMARC for every
     * domain, add the MTA-STS records only under a policy-
     * publishing posture, and skip them under the insecure
     * posture.
     */
    public function suggestedRecordsAssemblyTestCase()
    {
        $secure = MailSiteFactory::suggestedDnsRecords(
            ['yioop.com'], 'spam', 100, 'postmaster@yioop.com');
        $hosts = array_column($secure['yioop.com'], 'host');
        $this->assertTrue(in_array('yioop.com', $hosts),
            "SPF record present at the apex host");
        $this->assertTrue(in_array('_dmarc.yioop.com', $hosts),
            "DMARC record present at _dmarc host");
        $this->assertTrue(in_array('_mta-sts.yioop.com', $hosts),
            "MTA-STS discovery record present under spam posture");
        $this->assertTrue(in_array('mta-sts.yioop.com', $hosts),
            "MTA-STS policy host record present under spam");
        $insecure = MailSiteFactory::suggestedDnsRecords(
            ['yioop.com'], 'insecure', 100, '');
        $hosts = array_column($insecure['yioop.com'], 'host');
        $this->assertTrue(!in_array('_mta-sts.yioop.com', $hosts),
            "no MTA-STS record under the insecure posture");
        $this->assertTrue(in_array('yioop.com', $hosts),
            "SPF still suggested under the insecure posture");
    }
    /**
     * When a DKIM selector and public-key record are supplied, the
     * suggestions include the <selector>._domainkey.<domain> TXT
     * record carrying that value; when no record is supplied (no
     * key generated) the DKIM row is omitted.
     */
    public function dkimRecordInSuggestionsTestCase()
    {
        $with = MailSiteFactory::suggestedDnsRecords(['yioop.com'],
            'spam', 100, '', 'yioop20260602',
            'v=DKIM1; k=rsa; p=ABCDEF');
        $dkim = null;
        foreach ($with['yioop.com'] as $row) {
            if ($row['host'] === 'yioop20260602._domainkey.yioop.com') {
                $dkim = $row;
            }
        }
        $this->assertTrue($dkim !== null,
            "DKIM record present at the selector host");
        $this->assertEqual('v=DKIM1; k=rsa; p=ABCDEF',
            $dkim['value'], "DKIM record carries the key value");
        $without = MailSiteFactory::suggestedDnsRecords(
            ['yioop.com'], 'spam', 100, '', 'yioop20260602', '');
        $hosts = array_column($without['yioop.com'], 'host');
        $this->assertTrue(
            !in_array('yioop20260602._domainkey.yioop.com', $hosts),
            "no DKIM row when no key record is supplied");
    }
    /**
     * A www. or mta-sts. host of a domain already in the list is
     * not given its own record set, since it is not an independent
     * mail domain; a bare www./mta-sts. host whose apex is not
     * listed still gets one.
     */
    public function subdomainOfListedApexSkippedTestCase()
    {
        $records = MailSiteFactory::suggestedDnsRecords(
            ['pollett.org', 'mta-sts.pollett.org',
            'www.pollett.org'], 'spam', 100, '');
        $this->assertTrue(isset($records['pollett.org']),
            "apex mail domain keeps its record set");
        $this->assertTrue(!isset($records['mta-sts.pollett.org']),
            "mta-sts host of a listed apex is skipped");
        $this->assertTrue(!isset($records['www.pollett.org']),
            "www host of a listed apex is skipped");
        $alone = MailSiteFactory::suggestedDnsRecords(
            ['www.example.com'], 'spam', 100, '');
        $this->assertTrue(isset($alone['www.example.com']),
            "www host with no listed apex still gets a set");
    }
    /**
     * The bot route connects to the local MailSite on the submission
     * port.
     */
    public function botRouteUsesLocalSubmissionTestCase()
    {
        $client = MailSiteFactory::outboundSmtpClient(true);
        $this->assertEqual($client->server, "localhost",
            "bot route connects to localhost");
        /* The port is compared against what the client works out from
           the settings rather than against the built-in default, since
           a site that writes its own submission port down would fail a
           case that only knew the default. */
        $this->assertEqual($client->port, SmtpClient::submissionPort(),
            "bot route uses the submission port");
    }
    /**
     * The bot route logs in and sends as the reserved bot account.
     */
    public function botRouteSendsAsBotTestCase()
    {
        $client = MailSiteFactory::outboundSmtpClient(true);
        /* The name is compared against the bot identity the settings
           give rather than against the word bot, since a site whose own
           mail comes from another address names its bot after that. */
        $bot = MailSiteFactory::botLocalPart();
        $this->assertEqual($client->login, $bot,
            "bot route logs in as the bot identity");
        $this->assertEqual(strtok($client->sender_email, "@"), $bot,
            "bot route sends from the bot address");
    }
    /**
     * The bot route uses STARTTLS and accepts the local self-signed
     * certificate.
     */
    public function botRouteUsesStarttlsTestCase()
    {
        $client = MailSiteFactory::outboundSmtpClient(true);
        $this->assertEqual($client->secure, "starttls",
            "bot route secures with STARTTLS");
        $this->assertTrue($client->allow_self_signed,
            "bot route accepts the local certificate");
    }
    /**
     * The bot route stores no password; the secret is minted at login.
     */
    public function botRouteHasNoStoredPasswordTestCase()
    {
        $client = MailSiteFactory::outboundSmtpClient(true);
        $this->assertEqual($client->password, "",
            "bot route carries no stored password");
    }
    /**
     * The external route uses the configured relay fields.
     */
    public function externalRouteUsesConfiguredRelayTestCase()
    {
        $client = MailSiteFactory::outboundSmtpClient(false);
        $this->assertEqual($client->server, C\MAIL_SERVER,
            "external route uses the configured server");
        $this->assertEqual($client->login, C\MAIL_USERNAME,
            "external route uses the configured username");
    }
    /**
     * The bot local part is the name before the "@", or the whole
     * value when the sender has no domain.
     */
    public function botLocalPartFromSenderTestCase()
    {
        $this->assertEqual(
            MailSiteFactory::botLocalPart("no_reply@example.com"),
            "no_reply", "local part of a full sender address");
        $this->assertEqual(MailSiteFactory::botLocalPart("bot"),
            "bot", "bare sender name is its own local part");
    }
    /**
     * The bot address keeps a full sender address as is and appends a
     * domain to a bare sender name.
     */
    public function botAddressFromSenderTestCase()
    {
        $this->assertEqual(
            MailSiteFactory::botAddress("no_reply@example.com"),
            "no_reply@example.com", "full sender used as is");
        $this->assertEqual(
            strtok(MailSiteFactory::botAddress("bot"), "@"), "bot",
            "bare sender name gets a domain appended");
    }
    /**
     * connectLogLineNamesTheConnectionTestCase checks that the
     * connection log line names the protocol, the remote address and
     * port, and marks an implicit-TLS connection, and that it carries
     * no time of its own, since the server's log channel stamps one.
     */
    public function connectLogLineNamesTheConnectionTestCase()
    {
        $plain = MailSiteFactory::connectLogLine(['protocol' => 'SMTP',
            'remote_addr' => '192.0.2.5', 'remote_port' => 40000,
            'tls_active' => false]);
        $this->assertTrue(strpos($plain, "SMTP") !== false,
            "the protocol is named");
        $this->assertTrue(strpos($plain, "192.0.2.5:40000") !== false,
            "the remote address and port are named");
        $this->assertTrue(strpos($plain, "implicit TLS") === false,
            "a plain connection is not marked as implicit TLS");
        $secure = MailSiteFactory::connectLogLine(['protocol' => 'IMAP',
            'remote_addr' => '192.0.2.6', 'remote_port' => 993,
            'tls_active' => true]);
        $this->assertTrue(strpos($secure, "implicit TLS") !== false,
            "a TLS-wrapped connection is marked");
    }
    /**
     * secureLogLineNamesTheHandshakeTestCase checks that the handshake
     * log line names the mode, protocol, remote address and port, and
     * the outcome, giving the error when the handshake failed.
     */
    public function secureLogLineNamesTheHandshakeTestCase()
    {
        $ok = MailSiteFactory::secureLogLine(['protocol' => 'SMTP',
            'remote_addr' => '192.0.2.7', 'remote_port' => 587,
            'mode' => 'STARTTLS', 'ok' => true]);
        $this->assertTrue(strpos($ok, "STARTTLS") !== false,
            "the handshake mode is named");
        $this->assertTrue(strpos($ok, "ok") !== false,
            "a successful handshake reads ok");
        $bad = MailSiteFactory::secureLogLine(['protocol' => 'SMTP',
            'remote_addr' => '192.0.2.8', 'remote_port' => 465,
            'mode' => 'implicit', 'ok' => false,
            'error' => 'no shared cipher']);
        $this->assertTrue(strpos($bad, "no shared cipher") !== false,
            "a failed handshake carries the error");
    }
    /**
     * mailThisServerDeliveredToItselfSkipsJunkTestCase checks that a
     * message this server handed to a mailbox of its own reaches the
     * inbox under the Spam Insecure posture. Such a message never
     * crossed the network, so it carries no encrypted-hop mark and was
     * filed in Junk, which put a person's own webmail there. The
     * context names the source it came from, and that is what the
     * posture now lets past.
     */
    public function mailThisServerDeliveredToItselfSkipsJunkTestCase()
    {
        $info = ['from' => 'sender@example.com',
            'to' => 'root@example.com', 'bytes' => "Subject: t\r\n\r\nx"];
        $this->assertEqual(null,
            MailSiteFactory::junkInsecureMail($info,
            ['source' => 'webmail-compose']),
            "mail composed on the site for a local address is not junk");
        $this->assertEqual(null,
            MailSiteFactory::junkInsecureMail($info,
            ['source' => 'outbound-bounce']),
            "nor is a bounce notice this server delivered");
    }
    /**
     * mailOverAnEncryptedHopSkipsJunkTestCase checks the mark the
     * posture was written around still works, so letting locally
     * delivered mail past costs the encrypted case nothing.
     */
    public function mailOverAnEncryptedHopSkipsJunkTestCase()
    {
        $info = ['from' => 'sender@example.com',
            'to' => 'root@example.com', 'bytes' => "Subject: t\r\n\r\nx"];
        $this->assertEqual(null,
            MailSiteFactory::junkInsecureMail($info,
            ['REMOTE_ADDR' => '192.0.2.8', 'TLS_ACTIVE' => true]),
            "mail that arrived over an encrypted hop is not junk");
    }
}
/**
 * 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