<?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];
}
}